cli/azd/internal/cmd/show/show.go (450 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package show
import (
"context"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers/v3"
"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/cmd"
"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/azapi"
"github.com/azure/azure-dev/cli/azd/pkg/cloud"
"github.com/azure/azure-dev/cli/azd/pkg/contracts"
"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/keyvault"
"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/spf13/cobra"
"github.com/spf13/pflag"
)
var composeFeature = alpha.MustFeatureKey("compose")
type showFlags struct {
global *internal.GlobalCommandOptions
showSecrets bool
internal.EnvFlag
}
func (s *showFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) {
s.EnvFlag.Bind(local, global)
local.BoolVar(
&s.showSecrets,
"show-secrets",
false,
"Unmask secrets in output.",
)
s.global = global
}
func NewShowFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *showFlags {
flags := &showFlags{}
flags.Bind(cmd.Flags(), global)
return flags
}
func NewShowCmd() *cobra.Command {
cmd := &cobra.Command{
Short: "Display information about your app and its resources.",
}
return cmd
}
type showAction struct {
projectConfig *project.ProjectConfig
importManager *project.ImportManager
console input.Console
formatter output.Formatter
writer io.Writer
resourceService *azapi.ResourceService
kvService keyvault.KeyVaultService
envManager environment.Manager
infraResourceManager infra.ResourceManager
azdCtx *azdcontext.AzdContext
flags *showFlags
args []string
creds account.SubscriptionCredentialProvider
armClientOptions *arm.ClientOptions
featureManager *alpha.FeatureManager
lazyServiceManager *lazy.Lazy[project.ServiceManager]
lazyResourceManager *lazy.Lazy[project.ResourceManager]
portalUrlBase string
}
func NewShowAction(
console input.Console,
formatter output.Formatter,
writer io.Writer,
resourceService *azapi.ResourceService,
envManager environment.Manager,
infraResourceManager infra.ResourceManager,
projectConfig *project.ProjectConfig,
importManager *project.ImportManager,
featureManager *alpha.FeatureManager,
armClientOptions *arm.ClientOptions,
creds account.SubscriptionCredentialProvider,
kvService keyvault.KeyVaultService,
azdCtx *azdcontext.AzdContext,
flags *showFlags,
args []string,
lazyServiceManager *lazy.Lazy[project.ServiceManager],
lazyResourceManager *lazy.Lazy[project.ResourceManager],
cloud *cloud.Cloud,
) actions.Action {
return &showAction{
projectConfig: projectConfig,
importManager: importManager,
console: console,
formatter: formatter,
writer: writer,
resourceService: resourceService,
envManager: envManager,
infraResourceManager: infraResourceManager,
kvService: kvService,
featureManager: featureManager,
armClientOptions: armClientOptions,
creds: creds,
azdCtx: azdCtx,
args: args,
flags: flags,
lazyServiceManager: lazyServiceManager,
lazyResourceManager: lazyResourceManager,
portalUrlBase: cloud.PortalUrlBase,
}
}
func (s *showAction) Run(ctx context.Context) (*actions.ActionResult, error) {
s.console.ShowSpinner(ctx, "Gathering information about your app and its resources...", input.Step)
defer s.console.StopSpinner(ctx, "", input.Step)
res := contracts.ShowResult{
Name: s.projectConfig.Name,
Services: make(map[string]contracts.ShowService),
}
stableServices, err := s.importManager.ServiceStable(ctx, s.projectConfig)
if err != nil {
return nil, err
}
for _, svc := range stableServices {
path, err := getFullPathToProjectForService(svc)
if err != nil {
return nil, err
}
showSvc := contracts.ShowService{
Project: contracts.ShowServiceProject{
Path: path,
Type: showTypeFromLanguage(svc.Language),
},
}
res.Services[svc.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)
//
// Before we can discover resources, we need to load the current environment. We do this ourselves instead of
// having an environment injected into us so we can handle cases where the current environment doesn't exist (if we
// injected an environment, we'd prompt the user to see if they want to created one and we'd prefer not to have show
// interact with the user).
environmentName := s.flags.EnvironmentName
if environmentName == "" {
var err error
environmentName, err = s.azdCtx.GetDefaultEnvironmentName()
if err != nil {
log.Printf("could not determine current environment: %s, resource ids will not be available", err)
}
}
var subId, rgName string
if env, err := s.envManager.Get(ctx, environmentName); err != nil {
if errors.Is(err, environment.ErrNotFound) && s.flags.EnvironmentName != "" {
return nil, fmt.Errorf(
`"environment '%s' does not exist. You can create it with "azd env new"`, environmentName,
)
}
log.Printf("could not load environment: %s, resource ids will not be available", err)
} else {
if subId = env.GetSubscriptionId(); subId == "" {
log.Printf("provision has not been run, resource ids will not be available")
} else {
resourceManager, err := s.lazyResourceManager.GetValue()
if err != nil {
return nil, err
}
envName := env.Name()
if s.featureManager.IsEnabled(composeFeature) && len(s.args) > 0 {
name := s.args[0]
err := s.showResource(ctx, name, env)
if err != nil {
return nil, err
}
return nil, nil
}
rgName, err = s.infraResourceManager.FindResourceGroupForEnvironment(ctx, subId, envName)
if err == nil {
for _, serviceConfig := range stableServices {
svcName := serviceConfig.Name
resources, err := resourceManager.GetServiceResources(ctx, subId, rgName, serviceConfig)
if err == nil {
resourceIds := make([]string, len(resources))
for idx, res := range resources {
resourceIds[idx] = res.Id
}
resSvc := res.Services[svcName]
resSvc.Target = &contracts.ShowTargetArm{
ResourceIds: resourceIds,
}
resSvc.IngresUrl = s.serviceEndpoint(ctx, subId, serviceConfig, env)
res.Services[svcName] = resSvc
} else {
log.Printf("ignoring error determining resource id for service %s: %v", svcName, err)
}
}
} else {
log.Printf(
"ignoring error determining resource group for environment %s, resource ids will not be available: %v",
env.Name(),
err)
}
}
}
if s.formatter.Kind() == output.JsonFormat {
return nil, s.formatter.Format(res, s.writer, nil)
}
appEnvironments, err := s.envManager.List(ctx)
if err != nil {
return nil, err
}
uxEnvironments := make([]*ux.ShowEnvironment, len(appEnvironments))
for index, environment := range appEnvironments {
uxEnvironments[index] = &ux.ShowEnvironment{
Name: environment.Name,
IsCurrent: environment.Name == environmentName,
IsRemote: !environment.HasLocal && environment.HasRemote,
}
}
uxServices := make([]*ux.ShowService, len(res.Services))
var index int
for serviceName, service := range res.Services {
uxServices[index] = &ux.ShowService{
Name: serviceName,
IngresUrl: service.IngresUrl,
}
index++
}
s.console.MessageUxItem(ctx, &ux.Show{
AppName: s.projectConfig.Name,
Services: uxServices,
Environments: uxEnvironments,
AzurePortalLink: cmd.AzurePortalLink(s.portalUrlBase, subId, rgName),
})
return nil, nil
}
func (s *showAction) showResource(ctx context.Context, name string, env *environment.Environment) error {
id, err := infra.ResourceId(name, env)
if err != nil {
return fmt.Errorf("resolving '%s': %w", name, err)
}
subscriptionId := id.SubscriptionID
armOptions := s.armClientOptions
resourceOptions := showResourceOptions{
showSecrets: s.flags.showSecrets,
clientOpts: armOptions,
}
if res, ok := s.projectConfig.Resources[name]; ok {
resourceOptions.resourceSpec = res
}
credential, err := s.creds.CredentialForSubscription(ctx, subscriptionId)
if err != nil {
return err
}
resType := id.ResourceType.Namespace + "/" + id.ResourceType.Type
var item ux.UxItem
switch {
case strings.EqualFold(resType, "Microsoft.App/containerApps"):
item, err = showContainerApp(ctx, credential, id, resourceOptions)
if err != nil {
return err
}
default:
showRes := showResource{
env: env,
kvService: s.kvService,
resourceService: s.resourceService,
console: s.console,
}
item, err = showRes.showResourceGeneric(ctx, *id, resourceOptions)
if err != nil {
return err
}
}
if item != nil {
s.console.MessageUxItem(ctx, item)
}
return nil
}
type showResourceOptions struct {
showSecrets bool
resourceSpec *project.ResourceConfig
clientOpts *arm.ClientOptions
}
func showContainerApp(
ctx context.Context,
cred azcore.TokenCredential,
id *arm.ResourceID,
opts showResourceOptions) (*ux.ShowService, error) {
service := &ux.ShowService{
Name: id.Name,
Env: make(map[string]string),
}
client, err := armappcontainers.NewContainerAppsClient(id.SubscriptionID, cred, opts.clientOpts)
if err != nil {
return nil, fmt.Errorf("creating container-apps client: %w", err)
}
app, err := client.Get(ctx, id.ResourceGroupName, id.Name, nil)
if err != nil {
return nil, fmt.Errorf("getting container app: %w", err)
}
var secrets []*armappcontainers.ContainerAppSecret // secret name to value translations
if opts.showSecrets {
secretsRes, err := client.ListSecrets(ctx, id.ResourceGroupName, id.Name, nil)
if err != nil {
return nil, fmt.Errorf("listing secrets: %w", err)
}
secrets = secretsRes.Value
}
if len(app.Properties.Template.Containers) == 0 {
return service, nil
}
service.IngresUrl = fmt.Sprintf("https://%s", *app.Properties.Configuration.Ingress.Fqdn)
var container *armappcontainers.Container
if len(app.Properties.Template.Containers) == 1 {
container = app.Properties.Template.Containers[0]
} else {
for _, c := range app.Properties.Template.Containers {
if c.Name != nil && (strings.EqualFold(*c.Name, id.Name) || strings.EqualFold(*c.Name, "main")) {
container = c
break
}
}
if container == nil {
return nil, fmt.Errorf(
"container app %s has more than one container, and no containers match the name 'main' or '%s'",
id.Name,
id.Name)
}
}
envVar := container.Env
for _, env := range envVar {
if env.Name == nil {
continue
}
key := *env.Name
val := env.Value
if env.SecretRef != nil {
val = to.Ptr("*******")
// dereference the secret ref
for _, secret := range secrets {
if *env.SecretRef == *secret.Name {
val = secret.Value
break
}
}
}
service.Env[key] = *val
}
return service, nil
}
func (s *showAction) serviceEndpoint(
ctx context.Context, subId string, serviceConfig *project.ServiceConfig, env *environment.Environment) string {
resourceManager, err := s.lazyResourceManager.GetValue()
if err != nil {
log.Printf("error: getting lazy target-resource. Endpoints will be empty: %v", err)
return ""
}
targetResource, err := resourceManager.GetTargetResource(ctx, subId, serviceConfig)
if err != nil {
log.Printf("error: getting target-resource. Endpoints will be empty: %v", err)
return ""
}
serviceManager, err := s.lazyServiceManager.GetValue()
if err != nil {
log.Printf("error: getting lazy service manager. Endpoints will be empty: %v", err)
return ""
}
st, err := serviceManager.GetServiceTarget(ctx, serviceConfig)
if err != nil {
log.Printf("error: getting service target. Endpoints will be empty: %v", err)
return ""
}
endpoints, err := st.Endpoints(ctx, serviceConfig, targetResource)
if err != nil {
log.Printf("error: getting service endpoints. Endpoints might be empty: %v", err)
}
overriddenEndpoints := project.OverriddenEndpoints(ctx, serviceConfig, env)
if len(overriddenEndpoints) > 0 {
endpoints = overriddenEndpoints
}
if len(endpoints) == 0 {
return ""
}
return endpoints[0]
}
func showTypeFromLanguage(language project.ServiceLanguageKind) contracts.ShowType {
switch language {
case project.ServiceLanguageNone:
return contracts.ShowTypeNone
case project.ServiceLanguageDotNet, project.ServiceLanguageCsharp, project.ServiceLanguageFsharp:
return contracts.ShowTypeDotNet
case project.ServiceLanguagePython:
return contracts.ShowTypePython
case project.ServiceLanguageTypeScript, project.ServiceLanguageJavaScript:
return contracts.ShowTypeNode
case project.ServiceLanguageJava:
return contracts.ShowTypeJava
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 showTypeFromLanguage(svc.Language) != contracts.ShowTypeDotNet {
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, "+
"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,"+
" 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
}