Skip to content

Commit

Permalink
Add a (hidden, for now) show command for tooling use (#616)
Browse files Browse the repository at this point in the history
* WIP: Spike 'show'

* Use `show` in an integration test

* Use `type` not `language` in show output

This makes the set of values smaller and doesn't use the word
"language" which can be sort of confusing when it's set to something
like "dotnet".

As part of this, we normalize the output so that `type` is always one
of `dotnet`, `python` or `node` to make consumers lives easier.

* Move project sniffing logic into `show`

Consumers like Visual Studio need to understand the project file that
is assocated with a dotnet service, not just the path to the directory
the service lives in.

Initially, this logic was in it the project reader (which builds a
ProjectConfig), but that felt like the wrong location for the
logic. We can't add this logic to `ProjectConfig::GetProject` because
that call fails when the infrastructure for an appliaction has not
been provisioned, and we want `azd show` to work even in that case.

Longer term we should look into something that allows `GetProject` to
work evern when infrastructure is not created, and at that point move
this logic there, but for now we have this as a specific
implementation detail of the show command.

* Only support `--format json` for now

* Allow a single service to target multiple resources

* Decrease nesting by inverting condition

* Fix build issues after merge

* Fixes

* Kick CI
  • Loading branch information
ellismg authored Sep 21, 2022
1 parent 8208cba commit bdd2dfe
Show file tree
Hide file tree
Showing 6 changed files with 242 additions and 10 deletions.
5 changes: 3 additions & 2 deletions cli/azd/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,11 @@ For more information, visit the Azure Developer CLI Dev Hub: https://aka.ms/azur
cmd.AddCommand(pipelineCmd(opts))
cmd.AddCommand(provisionCmd(opts))
cmd.AddCommand(restoreCmd(opts))
cmd.AddCommand(upCmd(opts))
cmd.AddCommand(showCmd(opts))
cmd.AddCommand(telemetryCmd(opts))
cmd.AddCommand(templatesCmd(opts))
cmd.AddCommand(upCmd(opts))
cmd.AddCommand(versionCmd(opts))
cmd.AddCommand(telemetryCmd(opts))

return cmd
}
Expand Down
187 changes: 187 additions & 0 deletions cli/azd/cmd/show.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package cmd

import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"

"github.com/azure/azure-dev/cli/azd/internal"
"github.com/azure/azure-dev/cli/azd/pkg/commands"
"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/output"
"github.com/azure/azure-dev/cli/azd/pkg/project"
"github.com/spf13/cobra"
)

func showCmd(rootOptions *internal.GlobalCommandOptions) *cobra.Command {
action := func(ctx context.Context, cmd *cobra.Command, args []string, azdCtx *azdcontext.AzdContext) error {
console := input.GetConsole(ctx)

formatter := output.GetFormatter(ctx)
writer := output.GetWriter(ctx)

if err := ensureProject(azdCtx.ProjectPath()); err != nil {
return err
}

env, ctx, err := loadOrInitEnvironment(ctx, &rootOptions.EnvironmentName, azdCtx, console)
if err != nil {
return fmt.Errorf("loading environment: %w", err)
}

prj, err := project.LoadProjectConfig(azdCtx.ProjectPath(), env)
if err != nil {
return fmt.Errorf("loading project: %w", err)
}

res := showResult{
Services: make(map[string]showService, len(prj.Services)),
}

for name, svc := range prj.Services {
path, err := getFullPathToProjectForService(svc)
if err != nil {
return err
}

showSvc := showService{
Project: showServiceProject{
Path: path,
Type: showTypeFromLanguage(svc.Language),
},
}

res.Services[name] = showSvc
}

// Add information about the target of each service, if we can determine it (if the infrastructure has
// not been deployed, for example, we'll just not include target information)
resourceManager := infra.NewAzureResourceManager(ctx)

if resourceGroupName, err := resourceManager.FindResourceGroupForEnvironment(ctx, env); err == nil {
for name := range prj.Services {
if resources, err := project.GetServiceResources(ctx, resourceGroupName, name, env); err == nil {
resourceIds := make([]string, len(resources))
for idx, res := range resources {
resourceIds[idx] = res.Id
}

resSvc := res.Services[name]
resSvc.Target = &showTargetArm{
ResourceIds: resourceIds,
}
res.Services[name] = resSvc
} else {
log.Printf("ignoring error determining resource id for service %s: %v", name, err)
}
}
} else {
log.Printf("ignoring error determining resource group for environment %s, resource ids will not be available: %v", env.GetEnvName(), err)
}

return formatter.Format(res, writer, nil)
}

cmd := commands.Build(
commands.ActionFunc(action),
rootOptions,
"show",
"Display information about your application and its resources.",
nil,
)

output.AddOutputParam(cmd,
[]output.Format{output.JsonFormat},
output.NoneFormat,
)

cmd.Hidden = true

return cmd
}

type showResult struct {
Services map[string]showService `json:"services"`
}

type showService struct {
// Project contains information about the project that backs this service.
Project showServiceProject `json:"project"`
// Target contains information about the resource that the service is deployed
// to.
Target *showTargetArm `json:"target,omitempty"`
}

type showServiceProject struct {
// Path contains the path to the project for this service.
// When 'type' is 'dotnet', this includes the project file (i.e. Todo.Api.csproj).
Path string `json:"path"`
// The type of this project. One of "dotnet", "python", or "node"
Type string `json:"language"`
}

type showTargetArm struct {
ResourceIds []string `json:"resourceIds"`
}

func showTypeFromLanguage(language string) string {
switch language {
case "dotnet":
return "dotnet"
case "py", "python":
return "python"
case "ts", "js":
return "node"
default:
panic(fmt.Sprintf("unknown language %s", language))
}
}

// getFullPathToProjectForService returns the full path to the source project for a given service. For dotnet services,
// this includes the project file (e.g Todo.Api.csproj). For dotnet services, if the `path` component of the configuration
// does not include the project file, we attempt to determine it by looking for a single .csproj/.vbproj/.fsproj file
// in that directory. If there are multiple, an error is returned.
func getFullPathToProjectForService(svc *project.ServiceConfig) (string, error) {
if svc.Language != "dotnet" {
return svc.Path(), nil
}

stat, err := os.Stat(svc.Path())
if err != nil {
return "", fmt.Errorf("stating project %s: %w", svc.Path(), err)
} else if stat.IsDir() {
entries, err := os.ReadDir(svc.Path())
if err != nil {
return "", fmt.Errorf("listing files for service %s: %w", svc.Name, err)
}
var projectFile string
for _, entry := range entries {
switch strings.ToLower(filepath.Ext(entry.Name())) {
case ".csproj", ".fsproj", ".vbproj":
if projectFile != "" {
// we found multiple project files, we need to ask the user to specify which one
// corresponds to the service.
return "", fmt.Errorf("multiple .NET project files detected in %s for service %s, please include the name of the .NET project file in 'project' setting in %s for this service", svc.Path(), svc.Name, azdcontext.ProjectFileName)
} else {
projectFile = entry.Name()
}
}
}
if projectFile == "" {
return "", fmt.Errorf("could not determine the .NET project file for service %s, please include the name of the .NET project file in project setting in %s for this service", svc.Name, azdcontext.ProjectFileName)
} else {
if svc.RelativePath != "" {
svc.RelativePath = filepath.Join(svc.RelativePath, projectFile)
} else {
svc.Project.Path = filepath.Join(svc.Project.Path, projectFile)
}
}
}

return svc.Path(), nil
}
1 change: 1 addition & 0 deletions cli/azd/pkg/project/project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ services:
host: appservice
worker:
project: src/worker
language: js
host: containerapp
`
mockContext := mocks.NewMockContext(context.Background())
Expand Down
18 changes: 11 additions & 7 deletions cli/azd/pkg/project/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,7 @@ func (svc *Service) Deploy(ctx context.Context, azdCtx *azdcontext.AzdContext) (

// GetServiceResourceName attempts to find the name of the azure resource with the 'azd-service-name' tag set to the service key.
func GetServiceResourceName(ctx context.Context, resourceGroupName string, serviceName string, env *environment.Environment) (string, error) {
azCli := azcli.GetAzCli(ctx)
query := fmt.Sprintf("[?tags.\"azd-service-name\" =='%s']", serviceName)

res, err := azCli.ListResourceGroupResources(ctx, env.GetSubscriptionId(), resourceGroupName, &azcli.ListResourceGroupResourcesOptions{
JmesPathQuery: &query,
})

res, err := GetServiceResources(ctx, resourceGroupName, serviceName, env)
if err != nil {
return "", err
}
Expand All @@ -105,3 +99,13 @@ func GetServiceResourceName(ctx context.Context, resourceGroupName string, servi

return res[0].Name, nil
}

// GetServiceResources gets the resources tagged for a given service
func GetServiceResources(ctx context.Context, resourceGroupName string, serviceName string, env *environment.Environment) ([]azcli.AzCliResource, error) {
azCli := azcli.GetAzCli(ctx)
query := fmt.Sprintf("[?tags.\"azd-service-name\" =='%s']", serviceName)

return azCli.ListResourceGroupResources(ctx, env.GetSubscriptionId(), resourceGroupName, &azcli.ListResourceGroupResourcesOptions{
JmesPathQuery: &query,
})
}
39 changes: 39 additions & 0 deletions cli/azd/test/functional/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,30 @@ func Test_CLI_InfraCreateAndDeleteWebApp(t *testing.T) {
_, err = cli.RunCommand(ctx, "infra", "create")
require.NoError(t, err)

t.Logf("Running show\n")
out, err := cli.RunCommand(ctx, "show", "-o", "json", "--cwd", dir)
require.NoError(t, err)

var showRes struct {
Services map[string]struct {
Project struct {
Path string `json:"path"`
Language string `json:"language"`
} `json:"project"`
Target struct {
ResourceIds []string `json:"resourceIds"`
} `json:"target"`
} `json:"services"`
}
err = json.Unmarshal([]byte(out), &showRes)
require.NoError(t, err)

service, has := showRes.Services["web"]
require.True(t, has)
require.Equal(t, "dotnet", service.Project.Language)
require.Equal(t, "webapp.csproj", filepath.Base(service.Project.Path))
require.Equal(t, 1, len(service.Target.ResourceIds))

_, err = cli.RunCommand(ctx, "deploy")
require.NoError(t, err)

Expand Down Expand Up @@ -432,6 +456,21 @@ func Test_CLI_InfraCreateAndDeleteWebApp(t *testing.T) {

_, err = cli.RunCommand(ctx, "infra", "delete", "--force", "--purge")
require.NoError(t, err)

t.Logf("Running show (again)\n")
out, err = cli.RunCommand(ctx, "show", "-o", "json", "--cwd", dir)
require.NoError(t, err)

err = json.Unmarshal([]byte(out), &showRes)
require.NoError(t, err)

// Project information should be present, but since we have run infra delete, there shouldn't
// be any resource ids.
service, has = showRes.Services["web"]
require.True(t, has)
require.Equal(t, "dotnet", service.Project.Language)
require.Equal(t, "webapp.csproj", filepath.Base(service.Project.Path))
require.Nil(t, service.Target.ResourceIds)
}

// test for azd deploy, azd deploy --service
Expand Down
2 changes: 1 addition & 1 deletion cli/azd/test/samples/webapp/azure.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ metadata:
template: azd-test/webapptest@v1
services:
web:
project: src/dotnet/webapp.csproj
project: src/dotnet/
host: appservice
language: csharp

0 comments on commit bdd2dfe

Please sign in to comment.