cli/azd/cmd/init.go (510 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package cmd
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/MakeNowJust/heredoc/v2"
"github.com/azure/azure-dev/cli/azd/cmd/actions"
"github.com/azure/azure-dev/cli/azd/internal"
"github.com/azure/azure-dev/cli/azd/internal/repository"
"github.com/azure/azure-dev/cli/azd/internal/tracing"
"github.com/azure/azure-dev/cli/azd/internal/tracing/fields"
"github.com/azure/azure-dev/cli/azd/pkg/alpha"
"github.com/azure/azure-dev/cli/azd/pkg/environment"
"github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext"
"github.com/azure/azure-dev/cli/azd/pkg/exec"
"github.com/azure/azure-dev/cli/azd/pkg/extensions"
"github.com/azure/azure-dev/cli/azd/pkg/input"
"github.com/azure/azure-dev/cli/azd/pkg/lazy"
"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/pkg/templates"
"github.com/azure/azure-dev/cli/azd/pkg/tools"
"github.com/azure/azure-dev/cli/azd/pkg/tools/git"
"github.com/azure/azure-dev/cli/azd/pkg/workflow"
"github.com/fatih/color"
"github.com/joho/godotenv"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func newInitFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *initFlags {
flags := &initFlags{}
flags.Bind(cmd.Flags(), global)
return flags
}
func newInitCmd() *cobra.Command {
return &cobra.Command{
Use: "init",
Short: "Initialize a new application.",
}
}
type initFlags struct {
templatePath string
templateBranch string
templateTags []string
subscription string
location string
global *internal.GlobalCommandOptions
fromCode bool
up bool
internal.EnvFlag
}
func (i *initFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) {
local.StringVarP(
&i.templatePath,
"template",
"t",
"",
//nolint:lll
"Initializes a new application from a template. You can use Full URI, <owner>/<repository>, or <repository> if it's part of the azure-samples organization.",
)
local.StringVarP(
&i.templateBranch,
"branch",
"b",
"",
"The template branch to initialize from. Must be used with a template argument (--template or -t).")
local.StringSliceVarP(
&i.templateTags,
"filter",
"f",
[]string{},
"The tag(s) used to filter template results. Supports comma-separated values.",
)
local.StringVarP(
&i.subscription,
"subscription",
"s",
"",
"Name or ID of an Azure subscription to use for the new environment",
)
local.BoolVarP(
&i.fromCode,
"from-code",
"",
false,
"Initializes a new application from your existing code.",
)
local.BoolVarP(
&i.up,
"up",
"",
false,
"Provision and deploy to Azure after initializing the project from a template.",
)
local.StringVarP(&i.location, "location", "l", "", "Azure location for the new environment")
i.EnvFlag.Bind(local, global)
i.global = global
}
type initAction struct {
lazyAzdCtx *lazy.Lazy[*azdcontext.AzdContext]
lazyEnvManager *lazy.Lazy[environment.Manager]
console input.Console
cmdRun exec.CommandRunner
gitCli *git.Cli
flags *initFlags
repoInitializer *repository.Initializer
templateManager *templates.TemplateManager
featuresManager *alpha.FeatureManager
extensionsManager *extensions.Manager
azd workflow.AzdCommandRunner
}
func newInitAction(
lazyAzdCtx *lazy.Lazy[*azdcontext.AzdContext],
lazyEnvManager *lazy.Lazy[environment.Manager],
cmdRun exec.CommandRunner,
console input.Console,
gitCli *git.Cli,
flags *initFlags,
repoInitializer *repository.Initializer,
templateManager *templates.TemplateManager,
featuresManager *alpha.FeatureManager,
extensionsManager *extensions.Manager,
azd workflow.AzdCommandRunner,
) actions.Action {
return &initAction{
lazyAzdCtx: lazyAzdCtx,
lazyEnvManager: lazyEnvManager,
console: console,
cmdRun: cmdRun,
gitCli: gitCli,
flags: flags,
repoInitializer: repoInitializer,
templateManager: templateManager,
featuresManager: featuresManager,
extensionsManager: extensionsManager,
azd: azd,
}
}
func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) {
wd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("getting cwd: %w", err)
}
azdCtx := azdcontext.NewAzdContextWithDirectory(wd)
i.lazyAzdCtx.SetValue(azdCtx)
if i.flags.templateBranch != "" && i.flags.templatePath == "" {
return nil,
errors.New(
"using branch argument (-b or --branch) requires a template argument (--template or -t) to be specified")
}
// ensure that git is available
if err := tools.EnsureInstalled(ctx, []tools.ExternalTool{i.gitCli}...); err != nil {
return nil, err
}
// Command title
i.console.MessageUxItem(ctx, &ux.MessageTitle{
Title: "Initializing an app to run on Azure (azd init)",
})
// AZD supports having .env at the root of the project directory as the initial environment file.
// godotenv.Load() -> add all the values from the .env file in the process environment
// If AZURE_ENV_NAME is set in the .env file, it will be used to name the environment during env initialize.
if err := godotenv.Overload(); err != nil {
// ignore the error if the file does not exist
if !os.IsNotExist(err) {
return nil, fmt.Errorf("reading .env file: %w", err)
}
}
if i.flags.EnvFlag.EnvironmentName == "" ||
(i.flags.EnvFlag.EnvironmentName != "" && !i.flags.EnvFlag.FromArg()) {
// only azd init supports using .env to influence the command. The `-e` flag is linked to the
// env var AZURE_ENV_NAME, which means it could've be set either from ENV or from arg.
// re-setting the value here after loading the .env file overrides any value coming from the system env but
// doest not override the value coming from the arg.
i.flags.EnvFlag.EnvironmentName = os.Getenv(environment.EnvNameEnvVarName)
}
var existingProject bool
if _, err := os.Stat(azdCtx.ProjectPath()); err == nil {
existingProject = true
} else if errors.Is(err, os.ErrNotExist) {
existingProject = false
} else {
return nil, fmt.Errorf("checking if project exists: %w", err)
}
var initTypeSelect initType
if i.flags.templatePath != "" || len(i.flags.templateTags) > 0 {
// an explicit --template passed, always initialize from app template
initTypeSelect = initAppTemplate
}
if i.flags.fromCode {
if i.flags.templatePath != "" {
return nil, errors.New("only one of init modes: --template, or --from-code should be set")
}
initTypeSelect = initFromApp
}
if i.flags.templatePath == "" && !i.flags.fromCode && existingProject {
// only initialize environment when no mode is set explicitly
initTypeSelect = initEnvironment
}
if initTypeSelect == initUnknown {
initTypeSelect, err = promptInitType(i.console, ctx)
if err != nil {
return nil, err
}
}
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 initAppTemplate:
tracing.SetUsageAttributes(fields.InitMethod.String("template"))
template, err := i.initializeTemplate(ctx, azdCtx)
if err != nil {
return nil, err
}
if _, err := i.initializeEnv(ctx, azdCtx, template.Metadata); err != nil {
return nil, err
}
if i.flags.up {
// Prompt to deploy to Azure
deploy, err := i.console.Confirm(ctx, input.ConsoleOptions{
Message: "Do you want to run " + output.WithHighLightFormat("azd up") + " now?",
DefaultValue: true,
Help: "Template files have been initialized in your local directory. " +
"If you want to provision and deploy now without making changes, select Y. If not, select N.",
})
if err != nil {
return nil, err
}
if deploy {
// Call azd up
startTime := time.Now()
i.azd.SetArgs([]string{"up", "--cwd", azdCtx.ProjectDirectory()})
err := i.azd.ExecuteContext(ctx)
header = "New project initialized! Provision and deploy to Azure was completed in " +
ux.DurationAsText(since(startTime)) + "."
if err != nil {
return nil, err
}
}
}
case initFromApp:
tracing.SetUsageAttributes(fields.InitMethod.String("app"))
header = "Your app is ready for the cloud!"
followUp = "You can provision and deploy your app to Azure by running the " + output.WithHighLightFormat("azd up") +
" command in this directory. For more information on configuring your app, see " +
output.WithHighLightFormat("./next-steps.md")
entries, err := os.ReadDir(azdCtx.ProjectDirectory())
if err != nil {
return nil, fmt.Errorf("reading current directory: %w", err)
}
if len(entries) == 0 {
return nil, &internal.ErrorWithSuggestion{
Err: errors.New("no files found in the current directory"),
Suggestion: "Ensure you're in the directory where your app code is located and try again." +
" If you do not have code and would like to start with an app template, run '" +
output.WithHighLightFormat("azd init") + "' and select the option to " +
color.MagentaString("Use a template") + ".",
}
}
err = i.repoInitializer.InitFromApp(ctx, azdCtx, func() (*environment.Environment, error) {
return i.initializeEnv(ctx, azdCtx, templates.Metadata{})
})
if err != nil {
return nil, err
}
case initEnvironment:
env, err := i.initializeEnv(ctx, azdCtx, templates.Metadata{})
if err != nil {
return nil, err
}
header = fmt.Sprintf("Initialized environment %s.", env.Name())
followUp = ""
case initProject:
tracing.SetUsageAttributes(fields.InitMethod.String("project"))
composeAlphaEnabled := i.featuresManager.IsEnabled(composeFeature)
if !composeAlphaEnabled {
err = i.repoInitializer.InitializeMinimal(ctx, azdCtx)
if err != nil {
return nil, err
}
_, err := i.initializeEnv(ctx, azdCtx, templates.Metadata{})
if err != nil {
return nil, err
}
followUp = ""
} else {
fi, err := os.Stat(azdCtx.ProjectPath())
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if fi != nil {
return nil, fmt.Errorf("project already initialized")
}
name, err := i.console.Prompt(ctx, input.ConsoleOptions{
Message: "What is the name of your project?",
DefaultValue: azdcontext.ProjectName(azdCtx.ProjectDirectory()),
})
if err != nil {
return nil, err
}
prjConfig := project.ProjectConfig{
Name: name,
}
if composeAlphaEnabled {
prjConfig.MetaSchemaVersion = "alpha"
}
err = project.Save(ctx, &prjConfig, azdCtx.ProjectPath())
if err != nil {
return nil, fmt.Errorf("saving project config: %w", err)
}
followUp = "Run " + output.WithHighLightFormat("azd add") + " to add new Azure components to your project."
}
header = "Generated azure.yaml project file."
default:
panic("unhandled init type")
}
if err := i.initializeExtensions(ctx, azdCtx); err != nil {
return nil, fmt.Errorf("initializing project extensions: %w", err)
}
return &actions.ActionResult{
Message: &actions.ResultMessage{
Header: header,
FollowUp: followUp,
},
}, nil
}
var composeFeature = alpha.MustFeatureKey("compose")
type initType int
const (
initUnknown = iota
initFromApp
initAppTemplate
initProject
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",
"Create a minimal project",
},
})
if err != nil {
return initUnknown, err
}
switch selection {
case 0:
return initFromApp, nil
case 1:
return initAppTemplate, nil
case 2:
return initProject, nil
default:
panic("unhandled selection")
}
}
func (i *initAction) initializeTemplate(
ctx context.Context,
azdCtx *azdcontext.AzdContext) (templates.Template, error) {
err := i.repoInitializer.PromptIfNonEmpty(ctx, azdCtx)
if err != nil {
return templates.Template{}, err
}
var initFromTemplate *templates.Template
if i.flags.templatePath == "" {
// prompt for the template explicitly
template, err := templates.PromptTemplate(
ctx,
"Select a project template:",
i.templateManager,
i.console,
&templates.ListOptions{
Tags: i.flags.templateTags,
},
)
if err != nil {
return templates.Template{}, err
}
initFromTemplate = &template
} else {
initFromTemplate = &templates.Template{
RepositoryPath: i.flags.templatePath,
}
}
err = i.repoInitializer.Initialize(ctx, azdCtx, initFromTemplate, i.flags.templateBranch)
if err != nil {
return templates.Template{}, fmt.Errorf("init from template repository: %w", err)
}
return *initFromTemplate, nil
}
func (i *initAction) initializeEnv(
ctx context.Context,
azdCtx *azdcontext.AzdContext,
templateMetadata templates.Metadata) (*environment.Environment, 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)
}
base := filepath.Base(azdCtx.ProjectDirectory())
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)
}
// Environment manager requires azd context
// Azd context isn't available in init so lazy instantiating
// it here after the template is hydrated and the context is available
envManager, err := i.lazyEnvManager.GetValue()
if err != nil {
return nil, err
}
envSpec := environment.Spec{
Name: i.flags.EnvironmentName,
Subscription: i.flags.subscription,
Location: i.flags.location,
Examples: examples,
}
env, err := envManager.Create(ctx, envSpec)
if err != nil {
return nil, fmt.Errorf("loading environment: %w", err)
}
if err := azdCtx.SetProjectState(azdcontext.ProjectState{DefaultEnvironment: env.Name()}); err != nil {
return nil, fmt.Errorf("saving default environment: %w", err)
}
// Copy template metadata into environment values
for key, value := range templateMetadata.Variables {
env.DotenvSet(key, value)
}
for key, value := range templateMetadata.Config {
if err := env.Config.Set(key, value); err != nil {
return nil, fmt.Errorf("setting environment config: %w", err)
}
}
initialValuesFromEnv, err := repository.InitEnvFileValues()
if err != nil {
return nil, fmt.Errorf("loading initial env file values: %w", err)
}
for key, value := range initialValuesFromEnv {
env.DotenvSet(key, value)
}
if err := envManager.Save(ctx, env); err != nil {
return nil, fmt.Errorf("saving environment: %w", err)
}
return env, nil
}
// initializeExtensions installs extensions specified in the project config
func (i *initAction) initializeExtensions(ctx context.Context, azdCtx *azdcontext.AzdContext) error {
if !i.featuresManager.IsEnabled(extensions.FeatureExtensions) {
return nil
}
projectConfig, err := project.Load(ctx, azdCtx.ProjectPath())
if err != nil {
return fmt.Errorf("loading project config: %w", err)
}
// No extensions required
if projectConfig.RequiredVersions == nil || len(projectConfig.RequiredVersions.Extensions) == 0 {
return nil
}
installedExtensions, err := i.extensionsManager.ListInstalled()
if err != nil {
return fmt.Errorf("listing installed extensions: %w", err)
}
i.console.Message(ctx, "\nInstalling required extensions...")
for extensionId, versionConstraint := range projectConfig.RequiredVersions.Extensions {
stepMessage := fmt.Sprintf("Installing %s extension", output.WithHighLightFormat(extensionId))
i.console.ShowSpinner(ctx, stepMessage, input.Step)
installed, isInstalled := installedExtensions[extensionId]
if isInstalled {
stepMessage += output.WithGrayFormat(" (version %s already installed)", installed.Version)
i.console.StopSpinner(ctx, stepMessage, input.StepSkipped)
continue
} else {
installConstraint := "latest"
if versionConstraint != nil {
installConstraint = *versionConstraint
}
filterOptions := &extensions.FilterOptions{
Version: installConstraint,
}
extensionVersion, err := i.extensionsManager.Install(ctx, extensionId, filterOptions)
if err != nil {
i.console.StopSpinner(ctx, stepMessage, input.StepFailed)
return fmt.Errorf("installing extension %s: %w", extensionId, err)
}
stepMessage += output.WithGrayFormat(" (%s)", extensionVersion.Version)
i.console.StopSpinner(ctx, stepMessage, input.StepDone)
}
}
return nil
}
func getCmdInitHelpDescription(*cobra.Command) string {
return generateCmdHelpDescription("Initialize a new application in your current directory.",
[]string{
formatHelpNote(
fmt.Sprintf("Running %s without flags specified will prompt "+
"you to initialize using your existing code, or from a template.",
output.WithHighLightFormat("init"),
)),
formatHelpNote(
"To view all available sample templates, including those submitted by the azd community, visit: " +
output.WithLinkFormat("https://azure.github.io/awesome-azd") + "."),
})
}
func getCmdInitHelpFooter(*cobra.Command) string {
return generateCmdHelpSamplesBlock(map[string]string{
"Initialize a template to your current local directory from a GitHub repo.": fmt.Sprintf("%s %s",
output.WithHighLightFormat("azd init --template"),
output.WithWarningFormat("[GitHub repo URL]"),
),
"Initialize a template to your current local directory from a branch other than main.": fmt.Sprintf("%s %s %s %s",
output.WithHighLightFormat("azd init --template"),
output.WithWarningFormat("[GitHub repo URL]"),
output.WithHighLightFormat("--branch"),
output.WithWarningFormat("[Branch name]"),
),
})
}