cli/azd/pkg/tools/dotnet/dotnet.go (271 lines of code) (raw):

// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. package dotnet import ( "context" "encoding/json" "fmt" "log" "os" "path/filepath" "strconv" "strings" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/blang/semver/v4" ) var _ tools.ExternalTool = (*Cli)(nil) type Cli struct { commandRunner exec.CommandRunner } type responseContainerConfiguration struct { Config responseContainerConfigurationExpPorts `json:"config"` } type responseContainerConfigurationExpPorts struct { ExposedPorts map[string]interface{} `json:"ExposedPorts"` } type targetPort struct { port string protocol string } func (cli *Cli) Name() string { return ".NET CLI" } func (cli *Cli) InstallUrl() string { return "https://dotnet.microsoft.com/download" } func (cli *Cli) versionInfo() tools.VersionInfo { return tools.VersionInfo{ MinimumVersion: semver.Version{ Major: 6, Minor: 0, Patch: 3}, UpdateCommand: "Visit https://docs.microsoft.com/en-us/dotnet/core/releases-and-support to upgrade", } } func (cli *Cli) CheckInstalled(ctx context.Context) error { err := tools.ToolInPath("dotnet") if err != nil { return err } dotnetRes, err := cli.commandRunner.Run(ctx, newDotNetRunArgs("--version")) if err != nil { return fmt.Errorf("checking %s version: %w", cli.Name(), err) } log.Printf("dotnet version: %s", dotnetRes.Stdout) dotnetSemver, err := tools.ExtractVersion(dotnetRes.Stdout) if err != nil { return fmt.Errorf("converting to semver version fails: %w", err) } updateDetail := cli.versionInfo() if dotnetSemver.LT(updateDetail.MinimumVersion) { return &tools.ErrSemver{ToolName: cli.Name(), VersionInfo: updateDetail} } return nil } func (cli *Cli) Restore(ctx context.Context, project string) error { runArgs := newDotNetRunArgs("restore", project) _, err := cli.commandRunner.Run(ctx, runArgs) if err != nil { return fmt.Errorf("dotnet restore on project '%s' failed: %w", project, err) } return nil } func (cli *Cli) Build(ctx context.Context, project string, configuration string, output string) error { runArgs := newDotNetRunArgs("build", project) if configuration != "" { runArgs = runArgs.AppendParams("-c", configuration) } if output != "" { runArgs = runArgs.AppendParams("--output", output) } _, err := cli.commandRunner.Run(ctx, runArgs) if err != nil { return fmt.Errorf("dotnet build on project '%s' failed: %w", project, err) } return nil } func (cli *Cli) Publish(ctx context.Context, project string, configuration string, output string) error { runArgs := newDotNetRunArgs("publish", project) if configuration != "" { runArgs = runArgs.AppendParams("-c", configuration) } if output != "" { runArgs = runArgs.AppendParams("--output", output) } _, err := cli.commandRunner.Run(ctx, runArgs) if err != nil { return fmt.Errorf("dotnet publish on project '%s' failed: %w", project, err) } return nil } func (cli *Cli) PublishAppHostManifest( ctx context.Context, hostProject string, manifestPath string, dotnetEnv string, ) error { // TODO(ellismg): Before we GA manifest support, we should remove this debug tool, but being able to control what // manifest is used is helpful, while the manifest/generator is still being built. So if // `AZD_DEBUG_DOTNET_APPHOST_USE_FIXED_MANIFEST` is set, then we will expect to find apphost-manifest.json SxS with the // host project, and we just use that instead. if enabled, err := strconv.ParseBool(os.Getenv("AZD_DEBUG_DOTNET_APPHOST_USE_FIXED_MANIFEST")); err == nil && enabled { m, err := os.ReadFile(filepath.Join(filepath.Dir(hostProject), "apphost-manifest.json")) if err != nil { return fmt.Errorf( "reading apphost-manifest.json (did you mean to have AZD_DEBUG_DOTNET_APPHOST_USE_FIXED_MANIFEST set?): %w", err, ) } return os.WriteFile(manifestPath, m, osutil.PermissionFile) } runArgs := exec.NewRunArgs( "dotnet", "run", "--project", filepath.Base(hostProject), "--publisher", "manifest", "--output-path", manifestPath) runArgs = runArgs.WithCwd(filepath.Dir(hostProject)) // AppHosts may conditionalize their infrastructure based on the environment, so we need to pass the environment when we // are `dotnet run`ing the app host project to produce its manifest. var envArgs []string if dotnetEnv != "" { envArgs = append(envArgs, fmt.Sprintf("DOTNET_ENVIRONMENT=%s", dotnetEnv)) } if envArgs != nil { runArgs = runArgs.WithEnv(envArgs) } _, err := cli.commandRunner.Run(ctx, runArgs) if err != nil { return fmt.Errorf("dotnet run --publisher manifest on project '%s' failed: %w", hostProject, err) } return nil } // PublishContainer runs a `dotnet publish“ with `/t:PublishContainer`to build and publish the container. // It also gets port number by using `--getProperty:GeneratedContainerConfiguration`. func (cli *Cli) PublishContainer( ctx context.Context, project, configuration, imageName, server, username, password string, ) (int, error) { if !strings.Contains(imageName, ":") { imageName = fmt.Sprintf("%s:latest", imageName) } imageParts := strings.Split(imageName, ":") runArgs := newDotNetRunArgs("publish", project) runArgs = runArgs.AppendParams( "-r", "linux-x64", "-c", configuration, "/t:PublishContainer", fmt.Sprintf("-p:ContainerRepository=%s", imageParts[0]), fmt.Sprintf("-p:ContainerImageTag=%s", imageParts[1]), fmt.Sprintf("-p:ContainerRegistry=%s", server), "--getProperty:GeneratedContainerConfiguration", ) runArgs = runArgs.WithEnv([]string{ fmt.Sprintf("DOTNET_CONTAINER_REGISTRY_UNAME=%s", username), fmt.Sprintf("DOTNET_CONTAINER_REGISTRY_PWORD=%s", password), // legacy variables for dotnet SDK version < 8.0.400 fmt.Sprintf("SDK_CONTAINER_REGISTRY_UNAME=%s", username), fmt.Sprintf("SDK_CONTAINER_REGISTRY_PWORD=%s", password), }) result, err := cli.commandRunner.Run(ctx, runArgs) if err != nil { return 0, fmt.Errorf("dotnet publish on project '%s' failed: %w", project, err) } port, err := cli.getTargetPort(result.Stdout, project) if err != nil { return 0, fmt.Errorf("failed to get dotnet target port: %w with dotnet publish output '%s'", err, result.Stdout) } return port, nil } // getTargetPort parses the output of `dotnet publish` with `/t:PublishContainer` to get the port the container exposes. func (cli *Cli) getTargetPort(result, project string) (int, error) { // Ensure the output is a JSON object and it has a property named "config". If not, the project needs to be configured // to produce a container. // // We use json.NewDecoder instead of json.Unmarshal because sometimes the `dotnet` tool will put "helpful" messages like // a workload being out of date at the end of stdout, which would confuse us if we tried to Unmarshal all of result. var obj map[string]json.RawMessage _ = json.NewDecoder(strings.NewReader(result)).Decode(&obj) // if empty string or there's no config output if result == "" || obj["config"] == nil { return 0, &internal.ErrorWithSuggestion{ Err: fmt.Errorf("empty dotnet configuration output"), Suggestion: fmt.Sprintf("Ensure project '%s' is enabled for container support and try again. To enable SDK "+ "container support, set the 'EnableSdkContainerSupport' property to true in your project file", project, ), } } var targetPorts []targetPort var configOutput responseContainerConfiguration if err := json.NewDecoder(strings.NewReader(result)).Decode(&configOutput); err != nil { return 0, fmt.Errorf("unmarshal dotnet configuration output: %w", err) } var exposedPortOutput []string for key := range configOutput.Config.ExposedPorts { exposedPortOutput = append(exposedPortOutput, key) } // exposedPortOutput format is <PORT_NUM>[/PORT_TYPE>] for _, value := range exposedPortOutput { split := strings.Split(value, "/") if len(split) > 1 { targetPorts = append(targetPorts, targetPort{port: split[0], protocol: split[1]}) } else { // Provide a default tcp protocol if none is specified targetPorts = append(targetPorts, targetPort{port: split[0], protocol: "tcp"}) } } if len(exposedPortOutput) < 1 { return 0, fmt.Errorf("multiple dotnet port %s detected", targetPorts) } port, err := strconv.Atoi(targetPorts[0].port) if err != nil { return 0, fmt.Errorf("convert port %s to integer: %w", targetPorts[0].port, err) } return port, nil } func (cli *Cli) InitializeSecret(ctx context.Context, project string) error { runArgs := newDotNetRunArgs("user-secrets", "init", "--project", project) _, err := cli.commandRunner.Run(ctx, runArgs) if err != nil { return fmt.Errorf("failed to initialize secrets at project '%s': %w", project, err) } return nil } func (cli *Cli) SetSecrets(ctx context.Context, secrets map[string]string, project string) error { secretsJson, err := json.Marshal(secrets) if err != nil { return fmt.Errorf("failed to marshal secrets: %w", err) } // dotnet user-secrets now support setting multiple values at once // learn.microsoft.com/aspnet/core/security/app-secrets?view=aspnetcore-7.0&tabs=windows#set-multiple-secrets runArgs := newDotNetRunArgs("user-secrets", "set", "--project", project). WithStdIn(strings.NewReader(string(secretsJson))) _, err = cli.commandRunner.Run(ctx, runArgs) if err != nil { return fmt.Errorf("failed running %s secret set: %w", cli.Name(), err) } return nil } // GetMsBuildProperty uses -getProperty to fetch a property after evaluation, without executing the build. // // This only works for versions dotnet >= 8, MSBuild >= 17.8. // On older tool versions, this will return an error. func (cli *Cli) GetMsBuildProperty(ctx context.Context, project string, propertyName string) (string, error) { runArgs := newDotNetRunArgs("msbuild", project, fmt.Sprintf("--getProperty:%s", propertyName)) res, err := cli.commandRunner.Run(ctx, runArgs) if err != nil { return "", err } return res.Stdout, nil } // IsAspireHostProject returns true if the project at the given path has an MS Build Property named "IsAspireHost" which is // set to true or has a ProjectCapability named "Aspire". func (cli *Cli) IsAspireHostProject(ctx context.Context, projectPath string) (bool, error) { runArgs := newDotNetRunArgs("msbuild", projectPath, "--getProperty:IsAspireHost", "--getItem:ProjectCapability") res, err := cli.commandRunner.Run(ctx, runArgs) if err != nil { return false, fmt.Errorf("running dotnet msbuild on project '%s': %w", projectPath, err) } var result struct { Properties struct { IsAspireHost string `json:"IsAspireHost"` } `json:"Properties"` Items struct { ProjectCapability []struct { Identity string `json:"Identity"` } `json:"ProjectCapability"` } `json:"Items"` } if err := json.Unmarshal([]byte(res.Stdout), &result); err != nil { return false, fmt.Errorf("unmarshal dotnet msbuild output: %w", err) } hasAspireCapability := false for _, capability := range result.Items.ProjectCapability { if capability.Identity == "Aspire" { hasAspireCapability = true break } } return result.Properties.IsAspireHost == "true" || hasAspireCapability, nil } func NewCli(commandRunner exec.CommandRunner) *Cli { return &Cli{ commandRunner: commandRunner, } } // newDotNetRunArgs creates a new RunArgs to run the specified dotnet command. It sets the environment variable // to disable output of workload update notifications, to make it easier for us to parse the output. func newDotNetRunArgs(args ...string) exec.RunArgs { runArgs := exec.NewRunArgs("dotnet", args...) runArgs = runArgs.WithEnv([]string{ "DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE=1", "DOTNET_NOLOGO=1", }) return runArgs }