diff --git a/.vscode/cspell-github-user-aliases.txt b/.vscode/cspell-github-user-aliases.txt index 4676714fb87..d9b547a8f31 100644 --- a/.vscode/cspell-github-user-aliases.txt +++ b/.vscode/cspell-github-user-aliases.txt @@ -27,6 +27,7 @@ otiai10 pamelafox pbnj sebastianmattar +sergi sethvargo stretchr theckman diff --git a/cli/azd/.vscode/cspell-azd-dictionary.txt b/cli/azd/.vscode/cspell-azd-dictionary.txt index 54c8e9fe0f9..acde078e027 100644 --- a/cli/azd/.vscode/cspell-azd-dictionary.txt +++ b/cli/azd/.vscode/cspell-azd-dictionary.txt @@ -86,6 +86,7 @@ devcenters devcentersdk devdeviceid devel +diffmatchpatch discarder docf dockerfiles @@ -94,6 +95,7 @@ doublestar dskip eastus endregion +entra entraid envlist envname @@ -192,6 +194,7 @@ semconv serverfarms servicebus setenvs +skus snapshotter springapp sqlserver diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index 14c98cb7beb..0ad238d88e8 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -21,6 +21,7 @@ import ( "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/cmd" + "github.com/azure/azure-dev/cli/azd/internal/cmd/add" "github.com/azure/azure-dev/cli/azd/internal/telemetry" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/spf13/cobra" @@ -320,6 +321,11 @@ func NewRootCmd( }, }). UseMiddleware("hooks", middleware.NewHooksMiddleware) + root. + Add("add", &actions.ActionDescriptorOptions{ + Command: add.NewAddCmd(), + ActionResolver: add.NewAddAction, + }) // Register any global middleware defined by the caller if len(middlewareChain) > 0 { diff --git a/cli/azd/cmd/testdata/TestUsage-azd-add.snap b/cli/azd/cmd/testdata/TestUsage-azd-add.snap new file mode 100644 index 00000000000..ba9b8105569 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-add.snap @@ -0,0 +1,18 @@ + +Add a component to your application. (Alpha) + +Usage + azd add [flags] + +Flags + --docs : Opens the documentation for azd add in your web browser. + -h, --help : Gets help for add. + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/internal/appdetect/appdetect.go b/cli/azd/internal/appdetect/appdetect.go index 90cdf3297c4..e6103d3cbd7 100644 --- a/cli/azd/internal/appdetect/appdetect.go +++ b/cli/azd/internal/appdetect/appdetect.go @@ -265,7 +265,7 @@ func detectAny(ctx context.Context, detectors []projectDetector, path string, en log.Printf("Found project %s at %s", project.Language, path) // docker is an optional property of a project, and thus is different than other detectors - docker, err := detectDocker(path, entries) + docker, err := detectDockerInDirectory(path, entries) if err != nil { return nil, fmt.Errorf("detecting docker project: %w", err) } diff --git a/cli/azd/internal/appdetect/docker.go b/cli/azd/internal/appdetect/docker.go index f162158ab31..f1ecb645680 100644 --- a/cli/azd/internal/appdetect/docker.go +++ b/cli/azd/internal/appdetect/docker.go @@ -11,18 +11,19 @@ import ( "strings" ) -func detectDocker(path string, entries []fs.DirEntry) (*Docker, error) { +func detectDockerInDirectory(path string, entries []fs.DirEntry) (*Docker, error) { for _, entry := range entries { if strings.ToLower(entry.Name()) == "dockerfile" { dockerFilePath := filepath.Join(path, entry.Name()) - return detectDockerFromFile(dockerFilePath) + return DetectDocker(dockerFilePath) } } return nil, nil } -func detectDockerFromFile(dockerFilePath string) (*Docker, error) { +// DetectDocker detects Docker from a Dockerfile path. +func DetectDocker(dockerFilePath string) (*Docker, error) { file, err := os.Open(dockerFilePath) if err != nil { return nil, fmt.Errorf("reading Dockerfile at %s: %w", dockerFilePath, err) diff --git a/cli/azd/internal/appdetect/docker_test.go b/cli/azd/internal/appdetect/docker_test.go index 2581ddd8a8e..6921a2d7502 100644 --- a/cli/azd/internal/appdetect/docker_test.go +++ b/cli/azd/internal/appdetect/docker_test.go @@ -1,11 +1,12 @@ package appdetect import ( - "github.com/azure/azure-dev/cli/azd/pkg/osutil" - "github.com/stretchr/testify/assert" "os" "path/filepath" "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/osutil" + "github.com/stretchr/testify/assert" ) func TestParsePortsInLine(t *testing.T) { @@ -52,7 +53,7 @@ func TestDetectDockerFromFile(t *testing.T) { err = os.WriteFile(tempFile, []byte(tt.dockerFileContent), osutil.PermissionFile) assert.NoError(t, err) - docker, err := detectDockerFromFile(tempFile) + docker, err := DetectDocker(tempFile) assert.NoError(t, err) actual := docker.Ports assert.Equal(t, tt.expectedPorts, actual) diff --git a/cli/azd/internal/cmd/add/add.go b/cli/azd/internal/cmd/add/add.go new file mode 100644 index 00000000000..a406fd2b395 --- /dev/null +++ b/cli/azd/internal/cmd/add/add.go @@ -0,0 +1,379 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package add + +import ( + "context" + "fmt" + "io" + "log" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/azure/azure-dev/cli/azd/cmd/actions" + "github.com/azure/azure-dev/cli/azd/internal/repository" + "github.com/azure/azure-dev/cli/azd/pkg/account" + "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/infra" + "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/pkg/prompt" + "github.com/azure/azure-dev/cli/azd/pkg/workflow" + "github.com/azure/azure-dev/cli/azd/pkg/yamlnode" + "github.com/braydonk/yaml" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +func NewAddCmd() *cobra.Command { + return &cobra.Command{ + Use: "add", + Short: fmt.Sprintf("Add a component to your application. %s", output.WithWarningFormat("(Alpha)")), + } +} + +type AddAction struct { + azd workflow.AzdCommandRunner + azdCtx *azdcontext.AzdContext + env *environment.Environment + envManager environment.Manager + subManager *account.SubscriptionsManager + alphaManager *alpha.FeatureManager + creds account.SubscriptionCredentialProvider + rm infra.ResourceManager + appInit *repository.Initializer + armClientOptions *arm.ClientOptions + prompter prompt.Prompter + console input.Console +} + +var composeFeature = alpha.MustFeatureKey("compose") + +func (a *AddAction) Run(ctx context.Context) (*actions.ActionResult, error) { + if !a.alphaManager.IsEnabled(composeFeature) { + return nil, fmt.Errorf( + "compose is currently under alpha support and must be explicitly enabled."+ + " Run `%s` to enable this feature", alpha.GetEnableCommand(composeFeature), + ) + } + + prjConfig, err := project.Load(ctx, a.azdCtx.ProjectPath()) + if err != nil { + return nil, err + } + + selectMenu := a.selectMenu() + slices.SortFunc(selectMenu, func(a, b Menu) int { + return strings.Compare(a.Label, b.Label) + }) + + selections := make([]string, 0, len(selectMenu)) + for _, menu := range selectMenu { + selections = append(selections, menu.Label) + } + idx, err := a.console.Select(ctx, input.ConsoleOptions{ + Message: "What would you like to add?", + Options: selections, + }) + if err != nil { + return nil, err + } + + selected := selectMenu[idx] + + resourceToAdd := &project.ResourceConfig{} + var serviceToAdd *project.ServiceConfig + + ctxPrompt := promptCtx{Context: ctx, prj: prjConfig} + if strings.EqualFold(selected.Namespace, "host") { + svc, r, err := a.configureHost(a.console, ctxPrompt) + if err != nil { + return nil, err + } + + resourceToAdd = r + serviceToAdd = svc + } else { + r, err := selected.SelectResource(a.console, ctxPrompt) + if err != nil { + return nil, err + } + + resourceToAdd = r + } + + r, err := configure(resourceToAdd, a.console, ctxPrompt) + if err != nil { + return nil, err + } + resourceToAdd = r + + usedBy, err := promptUsedBy(resourceToAdd, a.console, ctxPrompt) + if err != nil { + return nil, err + } + + file, err := os.OpenFile(a.azdCtx.ProjectPath(), os.O_RDWR, osutil.PermissionFile) + if err != nil { + return nil, fmt.Errorf("reading project file: %w", err) + } + defer file.Close() + + decoder := yaml.NewDecoder(file) + decoder.SetScanBlockScalarAsLiteral(true) + + var doc yaml.Node + err = decoder.Decode(&doc) + if err != nil { + return nil, fmt.Errorf("failed to decode: %w", err) + } + + if serviceToAdd != nil { + serviceNode, err := yamlnode.Encode(serviceToAdd) + if err != nil { + panic(fmt.Sprintf("encoding yaml node: %v", err)) + } + + err = yamlnode.Set(&doc, fmt.Sprintf("services?.%s", serviceToAdd.Name), serviceNode) + if err != nil { + return nil, fmt.Errorf("finding services: %w", err) + } + } + + resourceNode, err := yamlnode.Encode(resourceToAdd) + if err != nil { + panic(fmt.Sprintf("encoding yaml node: %v", err)) + } + + err = yamlnode.Set(&doc, fmt.Sprintf("resources?.%s", resourceToAdd.Name), resourceNode) + if err != nil { + return nil, fmt.Errorf("setting resource: %w", err) + } + + for _, svc := range usedBy { + err = yamlnode.Append(&doc, fmt.Sprintf("resources.%s.uses[]?", svc), &yaml.Node{ + Kind: yaml.ScalarNode, + Value: resourceToAdd.Name, + }) + if err != nil { + return nil, fmt.Errorf("appending resource: %w", err) + } + } + + new, err := yaml.Marshal(&doc) + if err != nil { + return nil, fmt.Errorf("marshalling yaml: %w", err) + } + + newCfg, err := project.Parse(ctx, string(new)) + if err != nil { + return nil, fmt.Errorf("re-parsing yaml: %w", err) + } + + a.console.Message(ctx, fmt.Sprintf("\nPreviewing changes to %s:\n", color.BlueString("azure.yaml"))) + diffString, diffErr := DiffBlocks(prjConfig.Resources, newCfg.Resources) + if diffErr != nil { + a.console.Message(ctx, "Preview unavailable. Pass --debug for more details.\n") + log.Printf("add-diff: preview failed: %v", diffErr) + } else { + a.console.Message(ctx, diffString) + } + + confirm, err := a.console.Confirm(ctx, input.ConsoleOptions{ + Message: "Accept changes to azure.yaml?", + DefaultValue: true, + }) + if err != nil || !confirm { + return nil, err + } + + // Write modified YAML back to file + err = file.Truncate(0) + if err != nil { + return nil, fmt.Errorf("truncating file: %w", err) + } + _, err = file.Seek(0, io.SeekStart) + if err != nil { + return nil, fmt.Errorf("seeking to start of file: %w", err) + } + + encoder := yaml.NewEncoder(file) + encoder.SetIndent(2) + // preserve multi-line blocks style + encoder.SetAssumeBlockAsLiteral(true) + err = encoder.Encode(&doc) + if err != nil { + return nil, fmt.Errorf("failed to encode: %w", err) + } + + err = file.Close() + if err != nil { + return nil, fmt.Errorf("closing file: %w", err) + } + + a.console.MessageUxItem(ctx, &ux.ActionResult{ + SuccessMessage: "azure.yaml updated.", + }) + + // Use default project values for Infra when not specified in azure.yaml + if prjConfig.Infra.Module == "" { + prjConfig.Infra.Module = project.DefaultModule + } + if prjConfig.Infra.Path == "" { + prjConfig.Infra.Path = project.DefaultPath + } + + infraRoot := prjConfig.Infra.Path + if !filepath.IsAbs(infraRoot) { + infraRoot = filepath.Join(prjConfig.Path, infraRoot) + } + + if _, err := pathHasInfraModule(infraRoot, prjConfig.Infra.Module); err == nil { + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + FollowUp: "Run '" + color.BlueString("azd infra synth") + "' to re-synthesize the infrastructure, " + + "then run '" + color.BlueString("azd provision") + "' to provision these changes anytime later.", + }, + }, err + } + + verb := "provision" + verbCapitalized := "Provision" + followUpCmd := "provision" + + if serviceToAdd != nil { + verb = "provision and deploy" + verbCapitalized = "Provision and deploy" + followUpCmd = "up" + } + + a.console.Message(ctx, "") + provisionOption, err := selectProvisionOptions( + ctx, + a.console, + fmt.Sprintf("Do you want to %s these changes?", verb)) + if err != nil { + return nil, err + } + + if provisionOption == provisionPreview { + err = a.previewProvision(ctx, prjConfig, resourceToAdd, usedBy) + if err != nil { + return nil, err + } + + y, err := a.console.Confirm(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("%s these changes to Azure?", verbCapitalized), + DefaultValue: true, + }) + if err != nil { + return nil, err + } + + if !y { + provisionOption = provisionSkip + } else { + provisionOption = provision + } + } + + if provisionOption == provision { + a.azd.SetArgs([]string{followUpCmd}) + err = a.azd.ExecuteContext(ctx) + if err != nil { + return nil, err + } + + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + FollowUp: "Run '" + + color.BlueString(fmt.Sprintf("azd show %s", resourceToAdd.Name)) + + "' to show details about the newly provisioned resource.", + }, + }, nil + } + + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + FollowUp: fmt.Sprintf( + "Run '%s' to %s these changes anytime later.", + color.BlueString("azd %s", followUpCmd), + verb), + }, + }, err +} + +type provisionSelection int + +const ( + provisionUnknown = iota + provision + provisionPreview + provisionSkip +) + +func selectProvisionOptions( + ctx context.Context, + console input.Console, + msg string) (provisionSelection, error) { + selection, err := console.Select(ctx, input.ConsoleOptions{ + Message: msg, + Options: []string{ + "Yes (preview changes)", // 0 - preview + "Yes", // 1 - provision + "No", // 2 - no + }, + }) + if err != nil { + return provisionUnknown, err + } + + switch selection { + case 0: + return provisionPreview, nil + case 1: + return provision, nil + case 2: + return provisionSkip, nil + default: + panic("unhandled") + } +} + +func NewAddAction( + azdCtx *azdcontext.AzdContext, + envManager environment.Manager, + subManager *account.SubscriptionsManager, + alphaManager *alpha.FeatureManager, + env *environment.Environment, + creds account.SubscriptionCredentialProvider, + prompter prompt.Prompter, + rm infra.ResourceManager, + armClientOptions *arm.ClientOptions, + appInit *repository.Initializer, + azd workflow.AzdCommandRunner, + console input.Console) actions.Action { + return &AddAction{ + azdCtx: azdCtx, + console: console, + envManager: envManager, + subManager: subManager, + alphaManager: alphaManager, + env: env, + prompter: prompter, + rm: rm, + armClientOptions: armClientOptions, + appInit: appInit, + creds: creds, + azd: azd, + } +} diff --git a/cli/azd/internal/cmd/add/add_configure.go b/cli/azd/internal/cmd/add/add_configure.go new file mode 100644 index 00000000000..719feddd547 --- /dev/null +++ b/cli/azd/internal/cmd/add/add_configure.go @@ -0,0 +1,209 @@ +package add + +import ( + "context" + "fmt" + "slices" + "strings" + "unicode" + + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/fatih/color" +) + +type promptCtx struct { + context.Context + prj *project.ProjectConfig +} + +// configure fills in the fields for a resource. +func configure( + r *project.ResourceConfig, + console input.Console, + ctx promptCtx) (*project.ResourceConfig, error) { + switch r.Type { + case project.ResourceTypeHostContainerApp: + return fillUses(r, console, ctx) + case project.ResourceTypeOpenAiModel: + return fillAiModelName(r, console, ctx) + case project.ResourceTypeDbPostgres, + project.ResourceTypeDbMongo: + return fillDatabaseName(r, console, ctx) + case project.ResourceTypeDbRedis: + r.Name = "redis" + return r, nil + default: + return r, nil + } +} + +func fillDatabaseName( + r *project.ResourceConfig, + console input.Console, + ctx promptCtx) (*project.ResourceConfig, error) { + if r.Name != "" { + return r, nil + } + + for { + dbName, err := console.Prompt(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Input the name of the app database (%s)", r.Type.String()), + Help: "Hint: App database name\n\n" + + "Name of the database that the app connects to. " + + "This database will be created after running azd provision or azd up.", + }) + if err != nil { + return r, err + } + + if err := validateResourceName(dbName, ctx.prj); err != nil { + console.Message(ctx, err.Error()) + continue + } + + r.Name = dbName + break + } + + return r, nil +} + +func fillAiModelName( + r *project.ResourceConfig, + console input.Console, + ctx promptCtx) (*project.ResourceConfig, error) { + if r.Name != "" { + return r, nil + } + + modelProps, ok := r.Props.(project.AIModelProps) + defaultName := "" + + // provide a default suggestion using the underlying model name + if ok { + defaultName = modelProps.Model.Name + i := 1 + for { + if _, exists := ctx.prj.Resources[defaultName]; exists { + i++ + defaultName = fmt.Sprintf("%s-%d", defaultName, i) + } else { + break + } + } + } + + for { + modelName, err := console.Prompt(ctx, input.ConsoleOptions{ + Message: "What is the name of this model?", + DefaultValue: defaultName, + }) + if err != nil { + return nil, err + } + + if err := validateResourceName(modelName, ctx.prj); err != nil { + console.Message(ctx, err.Error()) + continue + } + + r.Name = modelName + break + } + + return r, nil +} + +func fillUses( + r *project.ResourceConfig, + console input.Console, + ctx promptCtx) (*project.ResourceConfig, error) { + type resourceDisplay struct { + Resource *project.ResourceConfig + Display string + } + res := make([]resourceDisplay, 0, len(ctx.prj.Resources)) + for _, r := range ctx.prj.Resources { + res = append(res, resourceDisplay{ + Resource: r, + Display: fmt.Sprintf( + "[%s]\t%s", + r.Type.String(), + r.Name), + }) + } + slices.SortFunc(res, func(a, b resourceDisplay) int { + comp := strings.Compare(a.Display, b.Display) + if comp == 0 { + return strings.Compare(a.Resource.Name, b.Resource.Name) + } + return comp + }) + + if len(res) > 0 { + labels := make([]string, 0, len(res)) + for _, r := range res { + labels = append(labels, r.Display) + } + if console.IsSpinnerInteractive() { + formatted, err := output.TabAlign(labels, 3) + if err != nil { + return nil, fmt.Errorf("formatting labels: %w", err) + } + labels = formatted + } + uses, err := console.MultiSelect(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Select the resources that %s uses", color.BlueString(r.Name)), + Options: labels, + }) + if err != nil { + return nil, err + } + + // MultiSelect returns string[] not int[], and we had lost the translation mapping with TabAlign. + // Currently, we use whitespace to splice the item from the formatting text. + for _, use := range uses { + for i := len(use) - 1; i >= 0; i-- { + if unicode.IsSpace(rune(use[i])) { + r.Uses = append(r.Uses, use[i+1:]) + break + } + } + } + } + + return r, nil +} + +func promptUsedBy( + r *project.ResourceConfig, + console input.Console, + ctx promptCtx) ([]string, error) { + svc := []string{} + for _, other := range ctx.prj.Resources { + if strings.HasPrefix(string(other.Type), "host.") && !slices.Contains(r.Uses, other.Name) { + svc = append(svc, other.Name) + } + } + slices.Sort(svc) + + if len(svc) > 0 { + message := "Select the service(s) that uses this resource" + if strings.HasPrefix(string(r.Type), "host.") { + message = "Select the front-end service(s) that uses this service (if applicable)" + } + uses, err := console.MultiSelect(ctx, input.ConsoleOptions{ + Message: message, + Options: svc, + }) + if err != nil { + return nil, err + } + + return uses, nil + } + + return nil, nil +} diff --git a/cli/azd/internal/cmd/add/add_configure_host.go b/cli/azd/internal/cmd/add/add_configure_host.go new file mode 100644 index 00000000000..e4f3225e420 --- /dev/null +++ b/cli/azd/internal/cmd/add/add_configure_host.go @@ -0,0 +1,256 @@ +package add + +import ( + "context" + "errors" + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/internal/appdetect" + "github.com/azure/azure-dev/cli/azd/internal/repository" + "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/output" + "github.com/azure/azure-dev/cli/azd/pkg/output/ux" + "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/fatih/color" +) + +func (a *AddAction) configureHost( + console input.Console, + ctx promptCtx) (*project.ServiceConfig, *project.ResourceConfig, error) { + prj, err := a.promptCodeProject(ctx) + if err != nil { + return nil, nil, err + } + + svcSpec, err := a.projectAsService(ctx, prj) + if err != nil { + return nil, nil, err + } + + resSpec, err := addServiceAsResource( + ctx, + console, + svcSpec, + *prj) + if err != nil { + return nil, nil, err + } + + return svcSpec, resSpec, nil +} + +// promptCodeProject prompts the user to add a code project. +func (a *AddAction) promptCodeProject(ctx context.Context) (*appdetect.Project, error) { + path, err := promptDir(ctx, a.console, "Where is your app code project located?") + if err != nil { + return nil, err + } + + prj, err := appdetect.DetectDirectory(ctx, path) + if err != nil { + return nil, fmt.Errorf("detecting project: %w", err) + } + + if prj == nil { + // fallback, prompt for language + a.console.MessageUxItem(ctx, &ux.WarningMessage{Description: "Could not automatically detect language"}) + languages := slices.SortedFunc(maps.Keys(repository.LanguageMap), + func(a, b appdetect.Language) int { + return strings.Compare(a.Display(), b.Display()) + }) + + frameworks := slices.SortedFunc(maps.Keys(appdetect.WebUIFrameworks), + func(a, b appdetect.Dependency) int { + return strings.Compare(a.Display(), b.Display()) + }) + + selections := make([]string, 0, len(languages)+len(frameworks)) + entries := make([]any, 0, len(languages)+len(frameworks)) + + 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) + } + + // only apply tab-align if interactive + if a.console.IsSpinnerInteractive() { + formatted, err := output.TabAlign(selections, 3) + if err != nil { + return nil, fmt.Errorf("formatting selections: %w", err) + } + + selections = formatted + } + + i, err := a.console.Select(ctx, input.ConsoleOptions{ + Message: "Enter the language or framework", + Options: selections, + }) + if err != nil { + return nil, err + } + + prj := &appdetect.Project{ + Path: path, + DetectionRule: "Manual", + } + switch entries[i].(type) { + case appdetect.Language: + prj.Language = entries[i].(appdetect.Language) + case appdetect.Dependency: + framework := entries[i].(appdetect.Dependency) + prj.Language = framework.Language() + prj.Dependencies = []appdetect.Dependency{framework} + } + + // improve: appdetect: add troubleshooting for all kinds of languages + if prj.Language == appdetect.Python { + _, err := os.Stat(filepath.Join(path, "requirements.txt")) + if errors.Is(err, os.ErrNotExist) { + return nil, &internal.ErrorWithSuggestion{ + Err: errors.New("no requirements.txt found"), + //nolint:lll + Suggestion: "Run 'pip freeze > requirements.txt' or 'pip3 freeze > requirements.txt' to create a requirements.txt file for .", + } + } + } + return prj, nil + } + + return prj, nil +} + +// projectAsService prompts the user for enough information to create a service. +func (a *AddAction) projectAsService( + ctx promptCtx, + prj *appdetect.Project, +) (*project.ServiceConfig, error) { + _, supported := repository.LanguageMap[prj.Language] + if !supported { + return nil, fmt.Errorf("unsupported language: %s", prj.Language) + } + + svcName := azdcontext.ProjectName(prj.Path) + + for { + name, err := a.console.Prompt(ctx, input.ConsoleOptions{ + Message: "Enter a name for this service:", + DefaultValue: svcName, + }) + if err != nil { + return nil, err + } + + if err := validateServiceName(name, ctx.prj); err != nil { + a.console.Message(ctx, err.Error()) + continue + } + + if err := validateResourceName(name, ctx.prj); err != nil { + a.console.Message(ctx, err.Error()) + continue + } + + svcName = name + break + } + + confirm, err := a.console.Confirm(ctx, input.ConsoleOptions{ + Message: "azd will use " + color.MagentaString("Azure Container App") + " to host this project. Continue?", + DefaultValue: true, + }) + if err != nil { + return nil, err + } else if !confirm { + return nil, errors.New("cancelled") + } + + if prj.Docker == nil { + confirm, err := a.console.Confirm(ctx, input.ConsoleOptions{ + Message: "No Dockerfile found. Allow azd to automatically build a container image?", + DefaultValue: true, + }) + if err != nil { + return nil, err + } + + if !confirm { + path, err := promptDockerfile(ctx, a.console, "Where is your Dockerfile located?") + if err != nil { + return nil, err + } + + docker, err := appdetect.DetectDocker(path) + if err != nil { + return nil, err + } + + prj.Docker = docker + } + } + + svc, err := repository.ServiceFromDetect( + a.azdCtx.ProjectDirectory(), + svcName, + *prj) + if err != nil { + return nil, err + } + + return &svc, nil +} + +func addServiceAsResource( + ctx context.Context, + console input.Console, + svc *project.ServiceConfig, + prj appdetect.Project) (*project.ResourceConfig, error) { + resSpec := project.ResourceConfig{ + Name: svc.Name, + } + + if svc.Host == project.ContainerAppTarget { + resSpec.Type = project.ResourceTypeHostContainerApp + } else { + return nil, fmt.Errorf("unsupported service target: %s", svc.Host) + } + + props := project.ContainerAppProps{ + Port: -1, + } + + if svc.Docker.Path == "" { + // no Dockerfile is present, set port based on azd default builder logic + if _, err := os.Stat(filepath.Join(svc.RelativePath, "Dockerfile")); errors.Is(err, os.ErrNotExist) { + // default builder always specifies port 80 + props.Port = 80 + if svc.Language == project.ServiceLanguageJava { + props.Port = 8080 + } + } + } + + if props.Port == -1 { + port, err := repository.PromptPort(console, ctx, svc.Name, prj) + if err != nil { + return nil, err + } + + props.Port = port + } + + resSpec.Props = props + return &resSpec, nil +} diff --git a/cli/azd/internal/cmd/add/add_preview.go b/cli/azd/internal/cmd/add/add_preview.go new file mode 100644 index 00000000000..4329c94935a --- /dev/null +++ b/cli/azd/internal/cmd/add/add_preview.go @@ -0,0 +1,197 @@ +package add + +import ( + "bytes" + "context" + "fmt" + "io" + "log" + "strings" + "text/tabwriter" + + "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/fatih/color" +) + +// resourceMeta contains metadata of the resource +type resourceMeta struct { + // The underlying resource type. + AzureResourceType string + // UseEnvVars is the list of environment variable names that would be populated when this resource is used + UseEnvVars []string +} + +func metadata(r *project.ResourceConfig) resourceMeta { + res := resourceMeta{} + + // These are currently duplicated, static values maintained separately from the backend generation files + // If updating resources.bicep, these values should also be updated. + switch r.Type { + case project.ResourceTypeHostContainerApp: + res.AzureResourceType = "Microsoft.App/containerApps" + res.UseEnvVars = []string{ + strings.ToUpper(r.Name) + "_BASE_URL", + } + case project.ResourceTypeDbRedis: + res.AzureResourceType = "Microsoft.Cache/redis" + res.UseEnvVars = []string{ + "REDIS_HOST", + "REDIS_PORT", + "REDIS_ENDPOINT", + "REDIS_PASSWORD", + "REDIS_URL", + } + case project.ResourceTypeDbPostgres: + res.AzureResourceType = "Microsoft.DBforPostgreSQL/flexibleServers/databases" + res.UseEnvVars = []string{ + "POSTGRES_HOST", + "POSTGRES_USERNAME", + "POSTGRES_DATABASE", + "POSTGRES_PASSWORD", + "POSTGRES_PORT", + "POSTGRES_URL", + } + case project.ResourceTypeDbMongo: + res.AzureResourceType = "Microsoft.DocumentDB/databaseAccounts/mongodbDatabases" + res.UseEnvVars = []string{ + "MONGODB_URL", + } + case project.ResourceTypeOpenAiModel: + res.AzureResourceType = "Microsoft.CognitiveAccounts/accounts/deployments" + res.UseEnvVars = []string{ + "AZURE_OPENAI_ENDPOINT", + "Keyless (Microsoft Entra ID)", + } + } + return res +} + +func (a *AddAction) previewProvision( + ctx context.Context, + prjConfig *project.ProjectConfig, + resourceToAdd *project.ResourceConfig, + usedBy []string, +) error { + a.console.ShowSpinner(ctx, "Previewing changes....", input.Step) + err := provisioning.EnsureSubscriptionAndLocation(ctx, a.envManager, a.env, a.prompter, nil) + if err != nil { + return err + } + + environmentDetails, err := getEnvDetails(ctx, a.env, a.subManager) + if err != nil { + log.Printf("failed getting environment details: %s", err) + } + + a.console.Message(ctx, fmt.Sprintf("\n%s\n", output.WithBold("Previewing Azure resource changes"))) + a.console.Message(ctx, "Environment: "+color.BlueString(a.env.Name())) + + if environmentDetails.Subscription != "" { + a.console.MessageUxItem(ctx, &environmentDetails) + } + + a.console.StopSpinner(ctx, "", input.StepDone) + + a.console.Message(ctx, fmt.Sprintf("%s\n", output.WithBold("Resources"))) + + previewWriter := previewWriter{w: a.console.GetWriter()} + w := tabwriter.NewWriter(&previewWriter, 0, 0, 5, ' ', 0) + + fmt.Fprintln(w, "b Name\tResource type") + meta := metadata(resourceToAdd) + fmt.Fprintf(w, "+ %s\t%s\n", resourceToAdd.Name, meta.AzureResourceType) + + w.Flush() + a.console.Message(ctx, fmt.Sprintf("\n%s\n", output.WithBold("Environment variables"))) + + if strings.HasPrefix(string(resourceToAdd.Type), "host.") { + for _, use := range resourceToAdd.Uses { + if res, ok := prjConfig.Resources[use]; ok { + fmt.Fprintf(w, " %s -> %s\n", resourceToAdd.Name, output.WithBold("%s", use)) + + meta := metadata(res) + for _, envVar := range meta.UseEnvVars { + fmt.Fprintf(w, "g + %s\n", envVar) + } + + fmt.Fprintln(w) + } + } + } else { + meta := metadata(resourceToAdd) + + for _, usedBy := range usedBy { + fmt.Fprintf(w, " %s -> %s\n", usedBy, output.WithBold("%s", resourceToAdd.Name)) + + for _, envVar := range meta.UseEnvVars { + fmt.Fprintf(w, "g + %s\n", envVar) + } + + fmt.Fprintln(w) + } + } + + a.console.Message(ctx, "") + return nil +} + +// previewWriter applies text transformations on preview text before writing to standard output. +// A control character is can be specified at the start of each line to apply transformations. +// +// Current control character transformations: +// - '+' -> the line is colored green +// - '-' -> the line is colored red +// - 'b' -> the line is bolded; this character is replaced with a space +// - 'g' -> the line is colored green; this character is replaced with a space +type previewWriter struct { + // the underlying writer to write to + w io.Writer + + // buffer for the current line + buf bytes.Buffer + // stores the current line start character + lineStartChar rune +} + +// Write implements the io.Writer interface +func (pw *previewWriter) Write(p []byte) (n int, err error) { + for i, b := range p { + if pw.buf.Len() == 0 && len(p) > 0 { + pw.lineStartChar = rune(p[0]) + + if pw.lineStartChar == 'b' || pw.lineStartChar == 'g' { + // hidden characters, replace with a space + b = ' ' + } + } + + if err := pw.buf.WriteByte(b); err != nil { + return i, err + } + + if b == '\n' { + transform := fmt.Sprintf + switch pw.lineStartChar { + case '+', 'g': + transform = color.GreenString + case '-': + transform = color.RedString + case 'b': + transform = output.WithBold + } + + _, err := pw.w.Write([]byte(transform(pw.buf.String()))) + if err != nil { + return i, err + } + + pw.buf.Reset() + continue + } + } + + return len(p), nil +} diff --git a/cli/azd/internal/cmd/add/add_select.go b/cli/azd/internal/cmd/add/add_select.go new file mode 100644 index 00000000000..382bf96219a --- /dev/null +++ b/cli/azd/internal/cmd/add/add_select.go @@ -0,0 +1,54 @@ +package add + +import ( + "maps" + "slices" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/project" +) + +// resourceSelection prompts the user to select a given resource type, returning the resulting resource configuration. +type resourceSelection func(console input.Console, ctx promptCtx) (*project.ResourceConfig, error) + +// A menu to be displayed. +type Menu struct { + // Namespace of the resource type. + Namespace string + // Label displayed in the menu. + Label string + + // SelectResource is the continuation that returns the resource with type filled in. + SelectResource resourceSelection +} + +func (a *AddAction) selectMenu() []Menu { + return []Menu{ + {Namespace: "db", Label: "Database", SelectResource: selectDatabase}, + {Namespace: "host", Label: "Host service"}, + {Namespace: "ai.openai", Label: "Azure OpenAI", SelectResource: a.selectOpenAi}, + } +} + +func selectDatabase(console input.Console, ctx promptCtx) (*project.ResourceConfig, error) { + resourceTypesDisplayMap := make(map[string]project.ResourceType) + for _, resourceType := range project.AllResourceTypes() { + if strings.HasPrefix(string(resourceType), "db.") { + resourceTypesDisplayMap[resourceType.String()] = resourceType + } + } + + r := &project.ResourceConfig{} + resourceTypesDisplay := slices.Sorted(maps.Keys(resourceTypesDisplayMap)) + dbOption, err := console.Select(ctx, input.ConsoleOptions{ + Message: "Which type of database?", + Options: resourceTypesDisplay, + }) + if err != nil { + return nil, err + } + + r.Type = resourceTypesDisplayMap[resourceTypesDisplay[dbOption]] + return r, nil +} diff --git a/cli/azd/internal/cmd/add/add_select_ai.go b/cli/azd/internal/cmd/add/add_select_ai.go new file mode 100644 index 00000000000..c28f5a92a2e --- /dev/null +++ b/cli/azd/internal/cmd/add/add_select_ai.go @@ -0,0 +1,192 @@ +package add + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "slices" + "strings" + + armruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/azure/azure-dev/cli/azd/pkg/azureutil" + "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/pkg/output/ux" + "github.com/azure/azure-dev/cli/azd/pkg/project" +) + +func (a *AddAction) selectOpenAi(console input.Console, ctx promptCtx) (r *project.ResourceConfig, err error) { + resourceToAdd := &project.ResourceConfig{} + aiOption, err := console.Select(ctx, input.ConsoleOptions{ + Message: "Which type of Azure OpenAI service?", + Options: []string{ + "Chat (GPT)", // 0 - chat + "Embeddings (Document search)", // 1 - embeddings + }}) + if err != nil { + return nil, err + } + + resourceToAdd.Type = project.ResourceTypeOpenAiModel + + var allModels []ModelList + for { + err = provisioning.EnsureSubscriptionAndLocation(ctx, a.envManager, a.env, a.prompter, nil) + if err != nil { + return nil, err + } + + cred, err := a.creds.CredentialForSubscription(ctx, a.env.GetSubscriptionId()) + if err != nil { + return nil, fmt.Errorf("getting credentials: %w", err) + } + + pipeline, err := armruntime.NewPipeline( + "cognitive-list", "1.0.0", cred, runtime.PipelineOptions{}, a.armClientOptions) + if err != nil { + return nil, fmt.Errorf("failed creating HTTP pipeline: %w", err) + } + + console.ShowSpinner( + ctx, + fmt.Sprintf("Fetching available models in %s...", a.env.GetLocation()), + input.Step) + + location := fmt.Sprintf( + //nolint:lll + "https://management.azure.com/subscriptions/%s/providers/Microsoft.CognitiveServices/locations/%s/models?api-version=2023-05-01", + a.env.GetSubscriptionId(), + a.env.GetLocation()) + req, err := runtime.NewRequest(ctx, http.MethodGet, location) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + resp, err := pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("making request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, runtime.NewResponseError(resp) + } + + body, err := runtime.Payload(resp) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + console.StopSpinner(ctx, "", input.Step) + var response ModelResponse + err = json.Unmarshal(body, &response) + if err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + for _, model := range response.Value { + if model.Kind == "OpenAI" && slices.ContainsFunc(model.Model.Skus, func(sku ModelSku) bool { + return sku.Name == "Standard" + }) { + switch aiOption { + case 0: + if model.Model.Name == "gpt-4o" || model.Model.Name == "gpt-4" { + allModels = append(allModels, model) + } + case 1: + if strings.HasPrefix(model.Model.Name, "text-embedding") { + allModels = append(allModels, model) + } + } + } + + } + if len(allModels) > 0 { + break + } + + _, err = a.rm.FindResourceGroupForEnvironment( + ctx, a.env.GetSubscriptionId(), a.env.Name()) + var notFoundError *azureutil.ResourceNotFoundError + if errors.As(err, ¬FoundError) { // not yet provisioned, we're safe here + console.MessageUxItem(ctx, &ux.WarningMessage{ + Description: fmt.Sprintf("No models found in %s", a.env.GetLocation()), + }) + confirm, err := console.Confirm(ctx, input.ConsoleOptions{ + Message: "Try a different location?", + }) + if err != nil { + return nil, err + } + if confirm { + a.env.SetLocation("") + continue + } + } else if err != nil { + return nil, fmt.Errorf("finding resource group: %w", err) + } + + return nil, fmt.Errorf("no models found in %s", a.env.GetLocation()) + } + + slices.SortFunc(allModels, func(a ModelList, b ModelList) int { + return strings.Compare(b.Model.SystemData.CreatedAt, a.Model.SystemData.CreatedAt) + }) + + displayModels := make([]string, 0, len(allModels)) + models := make([]Model, 0, len(allModels)) + for _, model := range allModels { + models = append(models, model.Model) + displayModels = append(displayModels, fmt.Sprintf("%s\t%s", model.Model.Name, model.Model.Version)) + } + + if console.IsSpinnerInteractive() { + displayModels, err = output.TabAlign(displayModels, 5) + if err != nil { + return nil, fmt.Errorf("writing models: %w", err) + } + } + + sel, err := console.Select(ctx, input.ConsoleOptions{ + Message: "Select the model", + Options: displayModels, + }) + if err != nil { + return nil, err + } + + resourceToAdd.Props = project.AIModelProps{ + Model: project.AIModelPropsModel{ + Name: models[sel].Name, + Version: models[sel].Version, + }, + } + + return resourceToAdd, nil +} + +type ModelResponse struct { + Value []ModelList `json:"value"` +} + +type ModelList struct { + Kind string `json:"kind"` + Model Model `json:"model"` +} + +type Model struct { + Name string `json:"name"` + Skus []ModelSku `json:"skus"` + Version string `json:"version"` + SystemData ModelSystemData `json:"systemData"` +} + +type ModelSku struct { + Name string `json:"name"` +} + +type ModelSystemData struct { + CreatedAt string `json:"createdAt"` +} diff --git a/cli/azd/internal/cmd/add/diff.go b/cli/azd/internal/cmd/add/diff.go new file mode 100644 index 00000000000..1668fc9e4a9 --- /dev/null +++ b/cli/azd/internal/cmd/add/diff.go @@ -0,0 +1,152 @@ +package add + +import ( + "fmt" + "slices" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/braydonk/yaml" + "github.com/fatih/color" + dmp "github.com/sergi/go-diff/diffmatchpatch" +) + +// DiffBlocks returns a textual diff of new - old. +// +// It compares the values in old and new, and generates a textual diff for each value difference between old and new. +// It doesn't currently support deletions of new from old. +func DiffBlocks(old map[string]*project.ResourceConfig, new map[string]*project.ResourceConfig) (string, error) { + diffObj := dmp.New() + + // dynamic programming: store marshaled entries for comparison + oldMarshaled := map[string]string{} + newMarshaled := map[string]string{} + + allDiffs := []diffBlock{} + for key, newVal := range new { + oldVal, ok := old[key] + if !ok { + contents, err := yaml.Marshal(newVal) + if err != nil { + return "", fmt.Errorf("marshaling new %v: %w", key, err) + } + allDiffs = append(allDiffs, diffBlock{ + Header: diffLine{Type: dmp.DiffInsert, Text: key + ":"}, + Lines: lineDiffsFromStr(dmp.DiffInsert, string(contents)), + Indent: 4, + }) + continue + } + + newContent, ok := newMarshaled[key] + if !ok { + content, err := yaml.Marshal(newVal) + if err != nil { + return "", fmt.Errorf("marshaling new %v: %w", key, err) + } + newContent = string(content) + newMarshaled[key] = newContent + } + + oldContent, ok := oldMarshaled[key] + if !ok { + content, err := yaml.Marshal(oldVal) + if err != nil { + return "", fmt.Errorf("marshaling old %v: %w", key, err) + } + oldContent = string(content) + oldMarshaled[key] = oldContent + } + + diffs := diffObj.DiffMain(oldContent, newContent, false) + if diffNotEq(diffs) { + allDiffs = append(allDiffs, diffBlock{ + Header: diffLine{Type: dmp.DiffEqual, Text: key + ":"}, + Lines: linesDiffsFromTextDiffs(diffs), + Indent: 4, + }) + } + } + + slices.SortFunc(allDiffs, func(a, b diffBlock) int { + return strings.Compare(a.Header.Text, b.Header.Text) + }) + var sb strings.Builder + for _, s := range allDiffs { + sb.WriteString(formatLine(s.Header.Type, s.Header.Text, 0)) + + for _, r := range s.Lines { + if len(r.Text) == 0 { // trim empty lines + continue + } + + sb.WriteString(formatLine(r.Type, r.Text, s.Indent)) + } + + sb.WriteString("\n") + } + + return sb.String(), nil +} + +func formatLine(op dmp.Operation, text string, indent int) string { + switch op { + case dmp.DiffInsert: + return color.GreenString("+ %s%s\n", strings.Repeat(" ", indent), text) + case dmp.DiffDelete: + return color.RedString("- %s%s\n", strings.Repeat(" ", indent), text) + case dmp.DiffEqual: + return fmt.Sprintf(" %s%s\n", strings.Repeat(" ", indent), text) + default: + panic("unreachable") + } +} + +func diffNotEq(diffs []dmp.Diff) bool { + for _, diff := range diffs { + if diff.Type != dmp.DiffEqual { + return true + } + } + + return false +} + +func lineDiffsFromStr(op dmp.Operation, s string) []diffLine { + var result []diffLine + + lines := strings.Split(s, "\n") + for _, line := range lines { + result = append(result, diffLine{Text: line, Type: op}) + } + + return result +} + +// diffBlock is a block of lines of text diffs to be displayed. +// The lines are indented by Indent. +type diffBlock struct { + Header diffLine + Lines []diffLine + Indent int +} + +// diffLine is a text diff on a line-by-line basis. +type diffLine struct { + Text string + Type dmp.Operation +} + +func linesDiffsFromTextDiffs(diffs []dmp.Diff) []diffLine { + var result []diffLine + + for _, diff := range diffs { + lines := strings.Split(diff.Text, "\n") + + for _, line := range lines { + result = append(result, diffLine{Text: line, Type: diff.Type}) + } + } + + return result +} diff --git a/cli/azd/internal/cmd/add/util.go b/cli/azd/internal/cmd/add/util.go new file mode 100644 index 00000000000..58b5c7e6d5a --- /dev/null +++ b/cli/azd/internal/cmd/add/util.go @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package add + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + + "github.com/azure/azure-dev/cli/azd/internal/names" + "github.com/azure/azure-dev/cli/azd/pkg/account" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output/ux" + "github.com/azure/azure-dev/cli/azd/pkg/project" +) + +func validateServiceName(name string, prj *project.ProjectConfig) error { + err := names.ValidateLabelName(name) + if err != nil { + return err + } + + if _, exists := prj.Services[name]; exists { + return fmt.Errorf("service with name '%s' already exists", name) + } + + return nil +} + +func validateResourceName(name string, prj *project.ProjectConfig) error { + err := names.ValidateLabelName(name) + if err != nil { + return err + } + + if _, exists := prj.Resources[name]; exists { + return fmt.Errorf("resource with name '%s' already exists", name) + } + + return nil +} + +// promptDir prompts the user to input a valid directory. +func promptDir( + ctx context.Context, + console input.Console, + message string) (string, error) { + for { + path, err := console.PromptFs(ctx, input.ConsoleOptions{ + Message: message, + }, input.FsOptions{ + SuggestOpts: input.FsSuggestOptions{ + ExcludeFiles: true, + }, + }) + 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 + } +} + +// promptDockerfile prompts the user to input a valid Dockerfile path or directory, +// returning the absolute Dockerfile path. +func promptDockerfile( + ctx context.Context, + console input.Console, + message string) (string, error) { + for { + path, err := console.PromptFs(ctx, input.ConsoleOptions{ + Message: message, + }, input.FsOptions{}) + if err != nil { + return "", err + } + + fs, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + console.Message(ctx, fmt.Sprintf("'%s' is not a valid file or directory", path)) + continue + } + + if err != nil { + return "", err + } + + if fs.IsDir() { + filePath := filepath.Join(path, "Dockerfile") + file, err := os.Stat(filePath) + if err != nil || file != nil && file.IsDir() { + console.Message( + ctx, + fmt.Sprintf("could not find 'Dockerfile' in '%s'. Hint: provide a direct path to a Dockerfile", path)) + continue + } + } + + path, err = filepath.Abs(path) + if err != nil { + return "", err + } + + return path, err + } +} + +// pathHasInfraModule returns true if there is a file named "" or "" in path. +func pathHasInfraModule(path, module string) (bool, error) { + files, err := os.ReadDir(path) + if err != nil { + return false, fmt.Errorf("error while iterating directory: %w", err) + } + + return slices.ContainsFunc(files, func(file fs.DirEntry) bool { + fileName := file.Name() + fileNameNoExt := strings.TrimSuffix(fileName, filepath.Ext(fileName)) + return !file.IsDir() && fileNameNoExt == module + }), nil +} + +func getEnvDetails( + ctx context.Context, + env *environment.Environment, + subMgr *account.SubscriptionsManager) (ux.EnvironmentDetails, error) { + details := ux.EnvironmentDetails{} + subscription, err := subMgr.GetSubscription(ctx, env.GetSubscriptionId()) + if err != nil { + return details, fmt.Errorf("getting subscription: %w", err) + } + + location, err := subMgr.GetLocation(ctx, env.GetSubscriptionId(), env.GetLocation()) + if err != nil { + return details, fmt.Errorf("getting location: %w", err) + } + details.Location = location.DisplayName + + var subscriptionDisplay string + if v, err := strconv.ParseBool(os.Getenv("AZD_DEMO_MODE")); err == nil && v { + subscriptionDisplay = subscription.Name + } else { + subscriptionDisplay = fmt.Sprintf("%s (%s)", subscription.Name, subscription.Id) + } + + details.Subscription = subscriptionDisplay + return details, nil +} diff --git a/cli/azd/internal/names/label.go b/cli/azd/internal/names/label.go index 44402d5e266..6a1c5639400 100644 --- a/cli/azd/internal/names/label.go +++ b/cli/azd/internal/names/label.go @@ -3,7 +3,38 @@ package names -import "strings" +import ( + "errors" + "regexp" + "strings" +) + +var rfc1123LabelRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$`) + +// ValidateLabelName checks if the given name is a valid RFC 1123 Label name. +func ValidateLabelName(name string) error { + if name == "" { + return errors.New("name cannot be empty") + } + + if len(name) > 63 { + return errors.New("name must be 63 characters or less") + } + + if !isLowerAsciiAlphaNumeric(rune(name[0])) { + return errors.New("name must start with a lower-cased alphanumeric character, i.e. a-z or 0-9") + } + + if !isLowerAsciiAlphaNumeric(rune(name[len(name)-1])) { + return errors.New("name must end with a lower-cased alphanumeric character, i.e. a-z or 0-9") + } + + if !rfc1123LabelRegex.MatchString(name) { + return errors.New("name must contain only lower-cased alphanumeric characters or '-', i.e. a-z, 0-9, or '-'") + } + + return nil +} //cspell:disable @@ -48,6 +79,10 @@ func cleanAlphaNumeric(name string) (hasSeparator bool, cleaned string) { return hasSeparator, sb.String() } +func isLowerAsciiAlphaNumeric(r rune) bool { + return ('0' <= r && r <= '9') || ('a' <= r && r <= 'z') +} + func isAsciiAlphaNumeric(r rune) bool { return ('0' <= r && r <= '9') || ('A' <= r && r <= 'Z') || ('a' <= r && r <= 'z') } diff --git a/cli/azd/internal/names/label_test.go b/cli/azd/internal/names/label_test.go index 71cb5f18cef..d9593d3bdfd 100644 --- a/cli/azd/internal/names/label_test.go +++ b/cli/azd/internal/names/label_test.go @@ -27,6 +27,7 @@ func TestLabelName(t *testing.T) { {"MixedWithNumbers", "my2Project3", "my2-project3"}, {"SpecialCharacters", "my_project!@#", "my-project"}, {"EmptyString", "", ""}, + {"DotOnly", ".", ""}, {"OnlySpecialCharacters", "@#$%^&*", ""}, } diff --git a/cli/azd/internal/repository/app_init.go b/cli/azd/internal/repository/app_init.go index 2fc70fd1b9b..cb211bc14b1 100644 --- a/cli/azd/internal/repository/app_init.go +++ b/cli/azd/internal/repository/app_init.go @@ -28,7 +28,7 @@ import ( "github.com/otiai10/copy" ) -var languageMap = map[appdetect.Language]project.ServiceLanguageKind{ +var LanguageMap = map[appdetect.Language]project.ServiceLanguageKind{ appdetect.DotNet: project.ServiceLanguageDotNet, appdetect.Java: project.ServiceLanguageJava, appdetect.JavaScript: project.ServiceLanguageJavaScript, @@ -395,69 +395,13 @@ func (i *Initializer) prjConfigFromDetect( svcMapping := map[string]string{} for _, prj := range detect.Services { - rel, err := filepath.Rel(root, prj.Path) + svc, err := ServiceFromDetect(root, "", prj) if err != nil { - return project.ProjectConfig{}, err + return config, err } - svc := project.ServiceConfig{} - svc.Host = project.ContainerAppTarget - svc.RelativePath = rel - - language, supported := languageMap[prj.Language] - if !supported { - 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, - } - } - - if prj.HasWebUIFramework() { - // By default, use 'dist'. This is common for frameworks such as: - // - TypeScript - // - Vite - svc.OutputPath = "dist" - - loop: - for _, dep := range prj.Dependencies { - switch dep { - case appdetect.JsNext: - // next.js works as SSR with default node configuration without static build output - svc.OutputPath = "" - break loop - case appdetect.JsVite: - svc.OutputPath = "dist" - break loop - case appdetect.JsReact: - // react from create-react-app uses 'build' when used, but this can be overridden - // by choice of build tool, such as when using Vite. - svc.OutputPath = "build" - case appdetect.JsAngular: - // angular uses dist/ - svc.OutputPath = "dist/" + filepath.Base(rel) - break loop - } - } - } - - name := filepath.Base(rel) - if name == "." { - name = config.Name - } - name = names.LabelName(name) - svc.Name = name - config.Services[name] = &svc - - svcMapping[prj.Path] = name + config.Services[svc.Name] = &svc + svcMapping[prj.Path] = svc.Name } if addResources { @@ -524,7 +468,7 @@ func (i *Initializer) prjConfigFromDetect( Port: -1, } - port, err := promptPort(i.console, ctx, name, svc) + port, err := PromptPort(i.console, ctx, name, svc) if err != nil { return config, err } @@ -560,3 +504,77 @@ func (i *Initializer) prjConfigFromDetect( return config, nil } + +// ServiceFromDetect creates a ServiceConfig from an appdetect project. +func ServiceFromDetect( + root string, + svcName string, + prj appdetect.Project) (project.ServiceConfig, error) { + svc := project.ServiceConfig{ + Name: svcName, + } + rel, err := filepath.Rel(root, prj.Path) + if err != nil { + return svc, err + } + + if svc.Name == "" { + dirName := filepath.Base(rel) + if dirName == "." { + dirName = filepath.Base(root) + } + + svc.Name = names.LabelName(dirName) + } + + svc.Host = project.ContainerAppTarget + svc.RelativePath = rel + + language, supported := LanguageMap[prj.Language] + if !supported { + return svc, fmt.Errorf("unsupported language: %s", prj.Language) + } + + svc.Language = language + + if prj.Docker != nil { + relDocker, err := filepath.Rel(prj.Path, prj.Docker.Path) + if err != nil { + return svc, err + } + + svc.Docker = project.DockerProjectOptions{ + Path: relDocker, + } + } + + if prj.HasWebUIFramework() { + // By default, use 'dist'. This is common for frameworks such as: + // - TypeScript + // - Vite + svc.OutputPath = "dist" + + loop: + for _, dep := range prj.Dependencies { + switch dep { + case appdetect.JsNext: + // next.js works as SSR with default node configuration without static build output + svc.OutputPath = "" + break loop + case appdetect.JsVite: + svc.OutputPath = "dist" + break loop + case appdetect.JsReact: + // react from create-react-app uses 'build' when used, but this can be overridden + // by choice of build tool, such as when using Vite. + svc.OutputPath = "build" + case appdetect.JsAngular: + // angular uses dist/ + svc.OutputPath = "dist/" + filepath.Base(rel) + break loop + } + } + } + + return svc, nil +} diff --git a/cli/azd/internal/repository/detect_confirm.go b/cli/azd/internal/repository/detect_confirm.go index 24969951dc7..e7191d271ae 100644 --- a/cli/azd/internal/repository/detect_confirm.go +++ b/cli/azd/internal/repository/detect_confirm.go @@ -64,7 +64,7 @@ func (d *detectConfirm) Init(projects []appdetect.Project, root string) { d.root = root for _, project := range projects { - if _, supported := languageMap[project.Language]; supported { + if _, supported := LanguageMap[project.Language]; supported { d.Services = append(d.Services, project) } @@ -310,7 +310,7 @@ func (d *detectConfirm) remove(ctx context.Context) error { } func (d *detectConfirm) add(ctx context.Context) error { - languages := slices.SortedFunc(maps.Keys(languageMap), + languages := slices.SortedFunc(maps.Keys(LanguageMap), func(a, b appdetect.Language) int { return strings.Compare(a.Display(), b.Display()) }) diff --git a/cli/azd/internal/repository/infra_confirm.go b/cli/azd/internal/repository/infra_confirm.go index 5ebdf810f8c..6cea992dea6 100644 --- a/cli/azd/internal/repository/infra_confirm.go +++ b/cli/azd/internal/repository/infra_confirm.go @@ -65,7 +65,7 @@ func (i *Initializer) infraSpecFromDetect( Port: -1, } - port, err := promptPort(i.console, ctx, name, svc) + port, err := PromptPort(i.console, ctx, name, svc) if err != nil { return scaffold.InfraSpec{}, err } @@ -205,7 +205,8 @@ func promptDbName(console input.Console, ctx context.Context, database appdetect } } -func promptPort( +// PromptPort prompts for port selection from an appdetect project. +func PromptPort( console input.Console, ctx context.Context, name string, diff --git a/cli/azd/internal/repository/prompt_util.go b/cli/azd/internal/repository/prompt_util.go index 2bfa7c73045..b7f36f41178 100644 --- a/cli/azd/internal/repository/prompt_util.go +++ b/cli/azd/internal/repository/prompt_util.go @@ -35,8 +35,12 @@ func promptDir( console input.Console, message string) (string, error) { for { - path, err := console.PromptDir(ctx, input.ConsoleOptions{ + path, err := console.PromptFs(ctx, input.ConsoleOptions{ Message: message, + }, input.FsOptions{ + SuggestOpts: input.FsSuggestOptions{ + ExcludeFiles: true, + }, }) if err != nil { return "", err diff --git a/cli/azd/pkg/input/console.go b/cli/azd/pkg/input/console.go index 4441bd6fed0..8f115ae89e0 100644 --- a/cli/azd/pkg/input/console.go +++ b/cli/azd/pkg/input/console.go @@ -13,7 +13,6 @@ import ( "log" "os" "os/signal" - "path/filepath" "runtime" "slices" "strconv" @@ -114,8 +113,8 @@ type Console interface { PromptDialog(ctx context.Context, dialog PromptDialog) (map[string]any, error) // Prompts the user for a single value Prompt(ctx context.Context, options ConsoleOptions) (string, error) - // Prompts the user for a directory path. - PromptDir(ctx context.Context, options ConsoleOptions) (string, error) + // PromptFs prompts the user for a filesystem path or directory. + PromptFs(ctx context.Context, options ConsoleOptions, fsOptions FsOptions) (string, error) // Prompts the user to select a single value from a set of values Select(ctx context.Context, options ConsoleOptions) (int, error) // Prompts the user to select zero or more values from a set of values @@ -621,53 +620,6 @@ func (c *AskerConsole) Prompt(ctx context.Context, options ConsoleOptions) (stri return response, nil } -// Prompts the user for a single value -func (c *AskerConsole) PromptDir(ctx context.Context, options ConsoleOptions) (string, error) { - var response string - - if c.promptClient != nil { - opts := promptOptions{ - Type: "directory", - Options: promptOptionsOptions{ - Message: options.Message, - Help: options.Help, - }, - } - - if value, ok := options.DefaultValue.(string); ok { - opts.Options.DefaultValue = to.Ptr[any](value) - } - - result, err := c.promptClient.Prompt(ctx, opts) - if errors.Is(err, promptCancelledErr) { - return "", terminal.InterruptErr - } else if err != nil { - return "", err - } - - if err := json.Unmarshal(result, &response); err != nil { - return "", fmt.Errorf("unmarshalling response: %w", err) - } - - return response, nil - } - - err := c.doInteraction(func(c *AskerConsole) error { - prompt := &survey.Input{ - Message: options.Message, - Help: options.Help, - Suggest: dirSuggestions, - } - - return c.asker(prompt, &response) - }) - if err != nil { - return response, err - } - c.updateLastBytes(afterIoSentinel) - return response, nil -} - func choicesFromOptions(options ConsoleOptions) []promptChoice { choices := make([]promptChoice, len(options.Options)) for i, option := range options.Options { @@ -1095,22 +1047,6 @@ func GetStepResultFormat(result error) SpinnerUxType { return formatResult } -// dirSuggestions provides suggestion completions for directories given the current input directory. -func dirSuggestions(input string) []string { - completions := []string{} - if input == "" { - completions = append(completions, ".") - } - - matches, _ := filepath.Glob(input + "*") - for _, match := range matches { - if fs, err := os.Stat(match); err == nil && fs.IsDir() { - completions = append(completions, match) - } - } - return completions -} - // Handle doing interactive calls. It checks if there's a spinner running to pause it before doing interactive actions. func (c *AskerConsole) doInteraction(promptFn func(c *AskerConsole) error) error { if c.spinner.Status() == yacspin.SpinnerRunning { diff --git a/cli/azd/pkg/input/console_fs.go b/cli/azd/pkg/input/console_fs.go new file mode 100644 index 00000000000..a5472457af7 --- /dev/null +++ b/cli/azd/pkg/input/console_fs.go @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package input + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/AlecAivazis/survey/v2" +) + +const currentDirDisplayed = "./ [current directory]" + +// PromptFs prompts the user for a filesystem path or directory +func (c *AskerConsole) PromptFs(ctx context.Context, options ConsoleOptions, fsOpts FsOptions) (string, error) { + var response string + + err := c.doInteraction(func(c *AskerConsole) error { + suggest := func(input string) []string { + return fsSuggestions( + fsOpts.SuggestOpts, + fsOpts.Root, + input) + } + prompt := &survey.Input{ + Message: options.Message, + Help: options.Help, + Suggest: suggest, + } + err := c.asker(prompt, &response) + if err != nil { + return err + } + + // translate the display sentinel value into the valid value + if response == currentDirDisplayed { + response = "." + string(filepath.Separator) + } + + return nil + }) + if err != nil { + return response, err + } + c.updateLastBytes(afterIoSentinel) + return response, nil +} + +// ValidatePathFunc is called on a path is provided by the user, or on a path that is suggested through auto-completion. +// +// If ok is true, the path is accepted and the prompt continues. +// Otherwise, if err is nil, msg is displayed to the user, and the user is re-prompted for a new path. +// Finally, if err is non-nil, the prompt exits immediately with err. +type ValidatePathFunc func(path string) (msg string, ok bool, err error) + +// FsOptions provides options for prompting a filesystem path or directory. +type FsOptions struct { + // Root directory. + Root string + + // Path suggestion options. + SuggestOpts FsSuggestOptions +} + +// FsSuggestOptions provides options for listing filesystem suggestions. +type FsSuggestOptions struct { + // Exclude the current directory './' in suggestions. Only applicable if displaying directories. + ExcludeCurrentDir bool + + // Include hidden files in suggestions. + IncludeHiddenFiles bool + + // Exclude directories from suggestions. + ExcludeDirectories bool + + // Exclude files from suggestions. + ExcludeFiles bool +} + +// fsSuggestions provides suggestion completions for files or directories given the current user input. +func fsSuggestions( + options FsSuggestOptions, + root string, + input string) []string { + fi, err := os.Stat(input) + if err == nil && !fi.IsDir() { // we have found the file + return []string{input} + } + + // we have an input that is either a partial file/directory, or a directory: + // suggest completions that help prefix-match the current input to the next closest file or directory + completions := []string{} + if input == "" && !options.ExcludeCurrentDir && !options.ExcludeDirectories { + // include current directory in the completions + completions = append(completions, currentDirDisplayed) + } + + entry := input + if len(root) > 0 { + entry = filepath.Join(root, input) + } + + matches, _ := filepath.Glob(entry + "*") + for _, m := range matches { + if !options.IncludeHiddenFiles && strings.HasPrefix(filepath.Base(m), ".") { + continue + } + + info, err := os.Stat(m) + if err != nil { + continue + } + + if options.ExcludeDirectories && info.IsDir() { + continue + } + + if options.ExcludeFiles && !info.IsDir() { + continue + } + + name := m + if info.IsDir() { + // add trailing slash to directories + name += string(os.PathSeparator) + } + + completions = append(completions, name) + } + + return completions +} diff --git a/cli/azd/pkg/output/table.go b/cli/azd/pkg/output/table.go index eea2a188326..651d23d01b7 100644 --- a/cli/azd/pkg/output/table.go +++ b/cli/azd/pkg/output/table.go @@ -149,4 +149,22 @@ func convertToSlice(obj interface{}) ([]interface{}, error) { return vv, nil } +// TabAlign transforms translates tab-separated columns in input into properly aligned text +// with the given padding for separation. +// For more information, refer to the tabwriter package. +func TabAlign(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 +} + var _ Formatter = (*TableFormatter)(nil) diff --git a/cli/azd/pkg/project/resources.go b/cli/azd/pkg/project/resources.go index 927aa682e65..9c1494ec15e 100644 --- a/cli/azd/pkg/project/resources.go +++ b/cli/azd/pkg/project/resources.go @@ -11,6 +11,16 @@ import ( type ResourceType string +func AllResourceTypes() []ResourceType { + return []ResourceType{ + ResourceTypeDbRedis, + ResourceTypeDbPostgres, + ResourceTypeDbMongo, + ResourceTypeHostContainerApp, + ResourceTypeOpenAiModel, + } +} + const ( ResourceTypeDbRedis ResourceType = "db.redis" ResourceTypeDbPostgres ResourceType = "db.postgres" diff --git a/cli/azd/test/mocks/mockinput/mock_console.go b/cli/azd/test/mocks/mockinput/mock_console.go index 695848f34c0..0f78d3c0413 100644 --- a/cli/azd/test/mocks/mockinput/mock_console.go +++ b/cli/azd/test/mocks/mockinput/mock_console.go @@ -150,9 +150,9 @@ func (c *MockConsole) Prompt(ctx context.Context, options input.ConsoleOptions) return value.(string), err } -func (c *MockConsole) PromptDir(ctx context.Context, options input.ConsoleOptions) (string, error) { +func (c *MockConsole) PromptFs(ctx context.Context, options input.ConsoleOptions, fs input.FsOptions) (string, error) { c.log = append(c.log, options.Message) - value, err := c.respond("PromptDir", options) + value, err := c.respond("PromptFs", options) return value.(string), err } diff --git a/go.mod b/go.mod index 48ecfb0398f..4c53018725b 100644 --- a/go.mod +++ b/go.mod @@ -95,6 +95,7 @@ require ( github.com/rivo/uniseg v0.2.0 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.3.4 // indirect + github.com/sergi/go-diff v1.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.8.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.8.0 // indirect diff --git a/go.sum b/go.sum index 1fa3b2b49d0..6aaeb4ccbf3 100644 --- a/go.sum +++ b/go.sum @@ -483,6 +483,8 @@ github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.3.4 h1:WM4IBnxH8B9TakiM2QD5LyNl9JSndh88QbHqVC+Pauc= github.com/segmentio/encoding v0.3.4/go.mod h1:n0JeuIqEQrQoPDGsjo8UNd1iA0U8d8+oHAA4E3G3OxM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sethvargo/go-retry v0.2.3 h1:oYlgvIvsju3jNbottWABtbnoLC+GDtLdBHxKWxQm/iU= github.com/sethvargo/go-retry v0.2.3/go.mod h1:1afjQuvh7s4gflMObvjLPaWgluLLyhA1wmVZ6KLpICw= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=