19a113de7a
Several users reported cases where error messages would cause a panic if they contained accented characters. I wasn't able to reproduce this failure locally, but tracked down the panic to logging gRPC calls. The Message field is typed as a string, which requires all of the characters to be valid UTF-8. This change runs each log string through the strings.ToValidUTF8 function, which will replace any invalid characters with the "unknown" character. This should prevent the the logger from panicking.
574 lines
19 KiB
Go
574 lines
19 KiB
Go
// Copyright 2016-2018, Pulumi Corporation. All rights reserved.
|
||
|
||
// pulumi-language-dotnet serves as the "language host" for Pulumi programs written in .NET. It is ultimately
|
||
// responsible for spawning the language runtime that executes the program.
|
||
//
|
||
// The program being executed is executed by a shim exe called `pulumi-language-dotnet-exec`. This script is
|
||
// written in the hosted language (in this case, C#) and is responsible for initiating RPC links to the resource
|
||
// monitor and engine.
|
||
//
|
||
// It's therefore the responsibility of this program to implement the LanguageHostServer endpoint by spawning
|
||
// instances of `pulumi-language-dotnet-exec` and forwarding the RPC request arguments to the command-line.
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"flag"
|
||
"fmt"
|
||
"io/ioutil"
|
||
"math/rand"
|
||
"os"
|
||
"os/exec"
|
||
"path"
|
||
"strings"
|
||
"syscall"
|
||
|
||
pbempty "github.com/golang/protobuf/ptypes/empty"
|
||
"github.com/pkg/errors"
|
||
"github.com/pulumi/pulumi/sdk/v2/go/common/util/cmdutil"
|
||
"github.com/pulumi/pulumi/sdk/v2/go/common/util/logging"
|
||
"github.com/pulumi/pulumi/sdk/v2/go/common/util/rpcutil"
|
||
"github.com/pulumi/pulumi/sdk/v2/go/common/version"
|
||
pulumirpc "github.com/pulumi/pulumi/sdk/v2/proto/go"
|
||
"google.golang.org/grpc"
|
||
)
|
||
|
||
var (
|
||
// A exit-code we recognize when the nodejs process exits. If we see this error, there's no
|
||
// need for us to print any additional error messages since the user already got a a good
|
||
// one they can handle.
|
||
dotnetProcessExitedAfterShowingUserActionableMessage = 32
|
||
)
|
||
|
||
// Launches the language host RPC endpoint, which in turn fires up an RPC server implementing the
|
||
// LanguageRuntimeServer RPC endpoint.
|
||
func main() {
|
||
var tracing string
|
||
var binary string
|
||
flag.StringVar(&tracing, "tracing", "", "Emit tracing to a Zipkin-compatible tracing endpoint")
|
||
flag.StringVar(&binary, "binary", "", "A relative or an absolute path to a precompiled .NET assembly to execute")
|
||
|
||
// You can use the below flag to request that the language host load a specific executor instead of probing the
|
||
// PATH. This can be used during testing to override the default location.
|
||
var givenExecutor string
|
||
flag.StringVar(&givenExecutor, "use-executor", "",
|
||
"Use the given program as the executor instead of looking for one on PATH")
|
||
|
||
flag.Parse()
|
||
args := flag.Args()
|
||
logging.InitLogging(false, 0, false)
|
||
cmdutil.InitTracing("pulumi-language-dotnet", "pulumi-language-dotnet", tracing)
|
||
var dotnetExec string
|
||
if givenExecutor == "" {
|
||
pathExec, err := exec.LookPath("dotnet")
|
||
if err != nil {
|
||
err = errors.Wrap(err, "could not find `dotnet` on the $PATH")
|
||
cmdutil.Exit(err)
|
||
}
|
||
|
||
logging.V(3).Infof("language host identified executor from path: `%s`", pathExec)
|
||
dotnetExec = pathExec
|
||
} else {
|
||
logging.V(3).Infof("language host asked to use specific executor: `%s`", givenExecutor)
|
||
dotnetExec = givenExecutor
|
||
}
|
||
|
||
// Optionally pluck out the engine so we can do logging, etc.
|
||
var engineAddress string
|
||
if len(args) > 0 {
|
||
engineAddress = args[0]
|
||
}
|
||
|
||
// Fire up a gRPC server, letting the kernel choose a free port.
|
||
port, done, err := rpcutil.Serve(0, nil, []func(*grpc.Server) error{
|
||
func(srv *grpc.Server) error {
|
||
host := newLanguageHost(dotnetExec, engineAddress, tracing, binary)
|
||
pulumirpc.RegisterLanguageRuntimeServer(srv, host)
|
||
return nil
|
||
},
|
||
}, nil)
|
||
if err != nil {
|
||
cmdutil.Exit(errors.Wrapf(err, "could not start language host RPC server"))
|
||
}
|
||
|
||
// Otherwise, print out the port so that the spawner knows how to reach us.
|
||
fmt.Printf("%d\n", port)
|
||
|
||
// And finally wait for the server to stop serving.
|
||
if err := <-done; err != nil {
|
||
cmdutil.Exit(errors.Wrapf(err, "language host RPC stopped serving"))
|
||
}
|
||
}
|
||
|
||
// dotnetLanguageHost implements the LanguageRuntimeServer interface
|
||
// for use as an API endpoint.
|
||
type dotnetLanguageHost struct {
|
||
exec string
|
||
engineAddress string
|
||
tracing string
|
||
binary string
|
||
}
|
||
|
||
func newLanguageHost(exec, engineAddress, tracing string, binary string) pulumirpc.LanguageRuntimeServer {
|
||
|
||
return &dotnetLanguageHost{
|
||
exec: exec,
|
||
engineAddress: engineAddress,
|
||
tracing: tracing,
|
||
binary: binary,
|
||
}
|
||
}
|
||
|
||
// GetRequiredPlugins computes the complete set of anticipated plugins required by a program.
|
||
func (host *dotnetLanguageHost) GetRequiredPlugins(
|
||
ctx context.Context,
|
||
req *pulumirpc.GetRequiredPluginsRequest) (*pulumirpc.GetRequiredPluginsResponse, error) {
|
||
|
||
logging.V(5).Infof("GetRequiredPlugins: %v", req.GetProgram())
|
||
|
||
if host.binary != "" {
|
||
logging.V(5).Infof("GetRequiredPlugins: no plugins can be listed when a binary is specified")
|
||
return &pulumirpc.GetRequiredPluginsResponse{}, nil
|
||
}
|
||
|
||
// Make a connection to the real engine that we will log messages to.
|
||
conn, err := grpc.Dial(
|
||
host.engineAddress,
|
||
grpc.WithInsecure(),
|
||
rpcutil.GrpcChannelOptions(),
|
||
)
|
||
if err != nil {
|
||
return nil, errors.Wrapf(err, "language host could not make connection to engine")
|
||
}
|
||
|
||
// Make a client around that connection. We can then make our own server that will act as a
|
||
// monitor for the sdk and forward to the real monitor.
|
||
engineClient := pulumirpc.NewEngineClient(conn)
|
||
|
||
// First do a `dotnet build`. This will ensure that all the nuget dependencies of the project
|
||
// are restored and locally available for us.
|
||
if err := host.DotnetBuild(ctx, req, engineClient); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// now, introspect the user project to see which pulumi resource packages it references.
|
||
pulumiPackages, err := host.DeterminePulumiPackages(ctx, engineClient)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Ensure we know where the local nuget package cache directory is. User can specify where that
|
||
// is located, so this makes sure we respect any custom location they may have.
|
||
packageDir, err := host.DetermineDotnetPackageDirectory(ctx, engineClient)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Now that we know the set of pulumi packages referenced and we know where packages have been restored to,
|
||
// we can examine each package to determine the corresponding resource-plugin for it.
|
||
|
||
plugins := []*pulumirpc.PluginDependency{}
|
||
packageToVersion := make(map[string]string)
|
||
for _, parts := range pulumiPackages {
|
||
packageName := parts[0]
|
||
packageVersion := parts[1]
|
||
|
||
if existingVersion := packageToVersion[packageName]; existingVersion == packageVersion {
|
||
// only include distinct dependencies.
|
||
continue
|
||
}
|
||
|
||
packageToVersion[packageName] = packageVersion
|
||
|
||
plugin, err := host.DeterminePluginDependency(ctx, packageDir, packageName, packageVersion)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if plugin != nil {
|
||
plugins = append(plugins, plugin)
|
||
}
|
||
}
|
||
|
||
return &pulumirpc.GetRequiredPluginsResponse{Plugins: plugins}, nil
|
||
}
|
||
|
||
func (host *dotnetLanguageHost) DeterminePulumiPackages(
|
||
ctx context.Context, engineClient pulumirpc.EngineClient) ([][]string, error) {
|
||
|
||
logging.V(5).Infof("GetRequiredPlugins: Determining pulumi packages")
|
||
|
||
// Run the `dotnet list package --include-transitive` command. Importantly, do not clutter the
|
||
// stream with the extra steps we're performing. This is just so we can determine the required
|
||
// plugins. And, after the first time we do this, subsequent runs will see that the plugin is
|
||
// installed locally and not need to do anything.
|
||
args := []string{"list", "package", "--include-transitive"}
|
||
commandStr := strings.Join(args, " ")
|
||
commandOutput, err := host.RunDotnetCommand(ctx, engineClient, args, false /*logToUser*/)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// expected output should be like so:
|
||
//
|
||
// Project 'Aliases' has the following package references
|
||
// [netcoreapp3.1]:
|
||
// Top-level Package Requested Resolved
|
||
// > Pulumi 1.5.0-preview-alpha.1572911568 1.5.0-preview-alpha.1572911568
|
||
//
|
||
// Transitive Package Resolved
|
||
// > Google.Protobuf 3.10.0
|
||
// > Grpc 2.24.0
|
||
outputLines := strings.Split(strings.Replace(commandOutput, "\r\n", "\n", -1), "\n")
|
||
|
||
sawPulumi := false
|
||
pulumiPackages := [][]string{}
|
||
for _, line := range outputLines {
|
||
fields := strings.Fields(line)
|
||
if len(fields) < 3 {
|
||
continue
|
||
}
|
||
|
||
// Has to start with `>` and have at least 3 chunks:
|
||
//
|
||
// > name requested_ver? resolved_ver
|
||
if fields[0] != ">" {
|
||
continue
|
||
}
|
||
|
||
// We only care about `Pulumi.` packages
|
||
packageName := fields[1]
|
||
if packageName == "Pulumi" {
|
||
sawPulumi = true
|
||
continue
|
||
}
|
||
|
||
if !strings.HasPrefix(packageName, "Pulumi.") {
|
||
continue
|
||
}
|
||
|
||
version := fields[len(fields)-1]
|
||
pulumiPackages = append(pulumiPackages, []string{packageName, version})
|
||
}
|
||
|
||
if !sawPulumi && len(pulumiPackages) == 0 {
|
||
return nil, errors.Errorf(
|
||
"Unexpected output from 'dotnet %v'. Program does not appear to reference any 'Pulumi.*' packages.",
|
||
commandStr)
|
||
}
|
||
|
||
logging.V(5).Infof("GetRequiredPlugins: Pulumi packages: %#v", pulumiPackages)
|
||
|
||
return pulumiPackages, nil
|
||
}
|
||
|
||
func (host *dotnetLanguageHost) DetermineDotnetPackageDirectory(
|
||
ctx context.Context, engineClient pulumirpc.EngineClient) (string, error) {
|
||
|
||
logging.V(5).Infof("GetRequiredPlugins: Determining package directory")
|
||
|
||
// Run the `dotnet nuget locals global-packages --list` command. Importantly, do not clutter
|
||
// the stream with the extra steps we're performing. This is just so we can determine the
|
||
// required plugins. And, after the first time we do this, subsequent runs will see that the
|
||
// plugin is installed locally and not need to do anything.
|
||
args := []string{"nuget", "locals", "global-packages", "--list"}
|
||
commandStr := strings.Join(args, " ")
|
||
commandOutput, err := host.RunDotnetCommand(ctx, engineClient, args, false /*logToUser*/)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
// expected output should be like so: "info : global-packages: /home/cyrusn/.nuget/packages/"
|
||
// so grab the portion after "global-packages:"
|
||
index := strings.Index(commandOutput, "global-packages:")
|
||
if index < 0 {
|
||
return "", errors.Errorf("Unexpected output from 'dotnet %v': %v", commandStr, commandOutput)
|
||
}
|
||
|
||
dir := strings.TrimSpace(commandOutput[index+len("global-packages:"):])
|
||
logging.V(5).Infof("GetRequiredPlugins: Package directory: %v", dir)
|
||
|
||
return dir, nil
|
||
}
|
||
|
||
func (host *dotnetLanguageHost) DeterminePluginDependency(
|
||
ctx context.Context, packageDir, packageName, packageVersion string) (*pulumirpc.PluginDependency, error) {
|
||
|
||
logging.V(5).Infof("GetRequiredPlugins: Determining plugin dependency: %v, %v, %v",
|
||
packageDir, packageName, packageVersion)
|
||
|
||
// Check for a `~/.nuget/packages/package_name/package_version/content/version.txt` file.
|
||
|
||
versionFilePath := path.Join(packageDir, strings.ToLower(packageName), packageVersion, "content", "version.txt")
|
||
logging.V(5).Infof("GetRequiredPlugins: version file path: %v", versionFilePath)
|
||
|
||
if _, err := os.Stat(versionFilePath); err != nil {
|
||
if os.IsNotExist(err) {
|
||
// Pulumi package doesn't contain a version.txt file. This is not a resource-plugin.
|
||
// just ignore it.
|
||
logging.V(5).Infof("GetRequiredPlugins: No such file")
|
||
return nil, nil
|
||
}
|
||
|
||
// some other error. report it as it means we can't read this important file.
|
||
logging.V(5).Infof("GetRequiredPlugins: err: %v", err)
|
||
return nil, err
|
||
}
|
||
|
||
b, err := ioutil.ReadFile(versionFilePath)
|
||
if err != nil {
|
||
logging.V(5).Infof("GetRequiredPlugins: err: %v", err)
|
||
return nil, err
|
||
}
|
||
|
||
// Given a package name like "Pulumi.Azure" lowercase the part after Pulumi. to get the plugin name "azure".
|
||
name := strings.ToLower(packageName[len("Pulumi."):])
|
||
|
||
version := strings.TrimSpace(bytes.NewBuffer(b).String())
|
||
if !strings.HasPrefix(version, "v") {
|
||
// Version file has stripped off the "v" that we need. So add it back here.
|
||
version = fmt.Sprintf("v%v", version)
|
||
}
|
||
|
||
result := pulumirpc.PluginDependency{
|
||
Name: name,
|
||
Version: version,
|
||
Kind: "resource",
|
||
}
|
||
|
||
logging.V(5).Infof("GetRequiredPlugins: Determining plugin dependency: %#v", result)
|
||
return &result, nil
|
||
}
|
||
|
||
func (host *dotnetLanguageHost) DotnetBuild(
|
||
ctx context.Context, req *pulumirpc.GetRequiredPluginsRequest, engineClient pulumirpc.EngineClient) error {
|
||
|
||
args := []string{"build", "-nologo"}
|
||
|
||
if req.GetProgram() != "" {
|
||
args = append(args, req.GetProgram())
|
||
}
|
||
|
||
// Run the `dotnet build` command. Importantly, report the output of this to the user
|
||
// (ephemerally) as it is happening so they're aware of what's going on and can see the progress
|
||
// of things.
|
||
_, err := host.RunDotnetCommand(ctx, engineClient, args, true /*logToUser*/)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (host *dotnetLanguageHost) RunDotnetCommand(
|
||
ctx context.Context, engineClient pulumirpc.EngineClient, args []string, logToUser bool) (string, error) {
|
||
|
||
commandStr := strings.Join(args, " ")
|
||
if logging.V(5) {
|
||
logging.V(5).Infoln("Language host launching process: ", host.exec, commandStr)
|
||
}
|
||
|
||
// Buffer the writes we see from dotnet from its stdout and stderr streams. We will display
|
||
// these ephemerally as `dotnet build` runs. If the build does fail though, we will dump
|
||
// messages back to our own stdout/stderr so they get picked up and displayed to the user.
|
||
streamID := rand.Int31()
|
||
|
||
infoBuffer := &bytes.Buffer{}
|
||
errorBuffer := &bytes.Buffer{}
|
||
|
||
infoWriter := &logWriter{
|
||
ctx: ctx,
|
||
logToUser: logToUser,
|
||
engineClient: engineClient,
|
||
streamID: streamID,
|
||
buffer: infoBuffer,
|
||
severity: pulumirpc.LogSeverity_INFO,
|
||
}
|
||
|
||
errorWriter := &logWriter{
|
||
ctx: ctx,
|
||
logToUser: logToUser,
|
||
engineClient: engineClient,
|
||
streamID: streamID,
|
||
buffer: errorBuffer,
|
||
severity: pulumirpc.LogSeverity_ERROR,
|
||
}
|
||
|
||
// Now simply spawn a process to execute the requested program, wiring up stdout/stderr directly.
|
||
cmd := exec.Command(host.exec, args...) // nolint: gas // intentionally running dynamic program name.
|
||
|
||
cmd.Stdout = infoWriter
|
||
cmd.Stderr = errorWriter
|
||
|
||
_, err := infoWriter.LogToUser(fmt.Sprintf("running 'dotnet %v'", commandStr))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
if err := cmd.Run(); err != nil {
|
||
// The command failed. Dump any data we collected to the actual stdout/stderr streams so
|
||
// they get displayed to the user.
|
||
os.Stdout.Write(infoBuffer.Bytes())
|
||
os.Stderr.Write(errorBuffer.Bytes())
|
||
|
||
if exiterr, ok := err.(*exec.ExitError); ok {
|
||
// If the program ran, but exited with a non-zero error code. This will happen often, since user
|
||
// errors will trigger this. So, the error message should look as nice as possible.
|
||
if status, stok := exiterr.Sys().(syscall.WaitStatus); stok {
|
||
return "", errors.Errorf(
|
||
"'dotnet %v' exited with non-zero exit code: %d", commandStr, status.ExitStatus())
|
||
}
|
||
|
||
return "", errors.Wrapf(exiterr, "'dotnet %v' exited unexpectedly", commandStr)
|
||
}
|
||
|
||
// Otherwise, we didn't even get to run the program. This ought to never happen unless there's
|
||
// a bug or system condition that prevented us from running the language exec. Issue a scarier error.
|
||
return "", errors.Wrapf(err, "Problem executing 'dotnet %v'", commandStr)
|
||
}
|
||
|
||
_, err = infoWriter.LogToUser(fmt.Sprintf("'dotnet %v' completed successfully", commandStr))
|
||
return infoBuffer.String(), err
|
||
}
|
||
|
||
type logWriter struct {
|
||
ctx context.Context
|
||
logToUser bool
|
||
engineClient pulumirpc.EngineClient
|
||
streamID int32
|
||
severity pulumirpc.LogSeverity
|
||
buffer *bytes.Buffer
|
||
}
|
||
|
||
func (w *logWriter) Write(p []byte) (n int, err error) {
|
||
n, err = w.buffer.Write(p)
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
return w.LogToUser(string(p))
|
||
}
|
||
|
||
func (w *logWriter) LogToUser(val string) (int, error) {
|
||
if w.logToUser {
|
||
_, err := w.engineClient.Log(w.ctx, &pulumirpc.LogRequest{
|
||
Message: strings.ToValidUTF8(val, "<22>"),
|
||
Urn: "",
|
||
Ephemeral: true,
|
||
StreamId: w.streamID,
|
||
Severity: w.severity,
|
||
})
|
||
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
}
|
||
|
||
return len(val), nil
|
||
}
|
||
|
||
// RPC endpoint for LanguageRuntimeServer::Run
|
||
func (host *dotnetLanguageHost) Run(ctx context.Context, req *pulumirpc.RunRequest) (*pulumirpc.RunResponse, error) {
|
||
config, err := host.constructConfig(req)
|
||
if err != nil {
|
||
err = errors.Wrap(err, "failed to serialize configuration")
|
||
return nil, err
|
||
}
|
||
|
||
args := []string{}
|
||
|
||
if host.binary != "" {
|
||
args = append(args, host.binary)
|
||
} else {
|
||
args = append(args, "run")
|
||
|
||
if req.GetProgram() != "" {
|
||
args = append(args, req.GetProgram())
|
||
}
|
||
}
|
||
|
||
if logging.V(5) {
|
||
commandStr := strings.Join(args, " ")
|
||
logging.V(5).Infoln("Language host launching process: ", host.exec, commandStr)
|
||
}
|
||
|
||
// Now simply spawn a process to execute the requested program, wiring up stdout/stderr directly.
|
||
var errResult string
|
||
cmd := exec.Command(host.exec, args...) // nolint: gas // intentionally running dynamic program name.
|
||
cmd.Stdout = os.Stdout
|
||
cmd.Stderr = os.Stderr
|
||
cmd.Env = host.constructEnv(req, config)
|
||
if err := cmd.Run(); err != nil {
|
||
if exiterr, ok := err.(*exec.ExitError); ok {
|
||
// If the program ran, but exited with a non-zero error code. This will happen often, since user
|
||
// errors will trigger this. So, the error message should look as nice as possible.
|
||
if status, stok := exiterr.Sys().(syscall.WaitStatus); stok {
|
||
// Check if we got special exit code that means "we already gave the user an
|
||
// actionable message". In that case, we can simply bail out and terminate `pulumi`
|
||
// without showing any more messages.
|
||
if status.ExitStatus() == dotnetProcessExitedAfterShowingUserActionableMessage {
|
||
return &pulumirpc.RunResponse{Error: "", Bail: true}, nil
|
||
}
|
||
|
||
err = errors.Errorf("Program exited with non-zero exit code: %d", status.ExitStatus())
|
||
} else {
|
||
err = errors.Wrapf(exiterr, "Program exited unexpectedly")
|
||
}
|
||
} else {
|
||
// Otherwise, we didn't even get to run the program. This ought to never happen unless there's
|
||
// a bug or system condition that prevented us from running the language exec. Issue a scarier error.
|
||
err = errors.Wrapf(err, "Problem executing program (could not run language executor)")
|
||
}
|
||
|
||
errResult = err.Error()
|
||
}
|
||
|
||
return &pulumirpc.RunResponse{Error: errResult}, nil
|
||
}
|
||
|
||
func (host *dotnetLanguageHost) constructEnv(req *pulumirpc.RunRequest, config string) []string {
|
||
env := os.Environ()
|
||
|
||
maybeAppendEnv := func(k, v string) {
|
||
if v != "" {
|
||
env = append(env, strings.ToUpper("PULUMI_"+k)+"="+v)
|
||
}
|
||
}
|
||
|
||
maybeAppendEnv("monitor", req.GetMonitorAddress())
|
||
maybeAppendEnv("engine", host.engineAddress)
|
||
maybeAppendEnv("project", req.GetProject())
|
||
maybeAppendEnv("stack", req.GetStack())
|
||
maybeAppendEnv("pwd", req.GetPwd())
|
||
maybeAppendEnv("dry_run", fmt.Sprintf("%v", req.GetDryRun()))
|
||
maybeAppendEnv("query_mode", fmt.Sprint(req.GetQueryMode()))
|
||
maybeAppendEnv("parallel", fmt.Sprint(req.GetParallel()))
|
||
maybeAppendEnv("tracing", host.tracing)
|
||
maybeAppendEnv("config", config)
|
||
|
||
return env
|
||
}
|
||
|
||
// constructConfig json-serializes the configuration data given as part of a RunRequest.
|
||
func (host *dotnetLanguageHost) constructConfig(req *pulumirpc.RunRequest) (string, error) {
|
||
configMap := req.GetConfig()
|
||
if configMap == nil {
|
||
return "", nil
|
||
}
|
||
|
||
configJSON, err := json.Marshal(configMap)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
return string(configJSON), nil
|
||
}
|
||
|
||
func (host *dotnetLanguageHost) GetPluginInfo(ctx context.Context, req *pbempty.Empty) (*pulumirpc.PluginInfo, error) {
|
||
return &pulumirpc.PluginInfo{
|
||
Version: version.Version,
|
||
}, nil
|
||
}
|