The langhost shares its standard out and standard error with the language executor that it is used (python/nodejs), so we must be sure to flush our stdout and stderr before reporting a Run failure to the engine.
262 lines
9.4 KiB
Go
262 lines
9.4 KiB
Go
// Copyright 2016-2018, Pulumi Corporation.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
// pulumi-language-python serves as the "language host" for Pulumi programs written in Python. It is ultimately
|
|
// responsible for spawning the language runtime that executes the program.
|
|
//
|
|
// The program being executed is executed by a shim script called `pulumi-language-python-exec`. This script is
|
|
// written in the hosted language (in this case, Python) 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-python-exec` and forwarding the RPC request arguments to the command-line.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
|
|
pbempty "github.com/golang/protobuf/ptypes/empty"
|
|
"github.com/pkg/errors"
|
|
"github.com/pulumi/pulumi/pkg/util/cmdutil"
|
|
"github.com/pulumi/pulumi/pkg/util/contract"
|
|
"github.com/pulumi/pulumi/pkg/util/logging"
|
|
"github.com/pulumi/pulumi/pkg/util/rpcutil"
|
|
"github.com/pulumi/pulumi/pkg/version"
|
|
pulumirpc "github.com/pulumi/pulumi/sdk/proto/go"
|
|
"google.golang.org/grpc"
|
|
)
|
|
|
|
const (
|
|
// By convention, the executor is the name of the current program (pulumi-language-python) plus this suffix.
|
|
pythonDefaultExec = "pulumi-language-python-exec" // the exec shim for Pulumi to run Python programs.
|
|
|
|
// The runtime expects the config object to be saved to this environment variable.
|
|
pulumiConfigVar = "PULUMI_CONFIG"
|
|
)
|
|
|
|
// Launches the language host RPC endpoint, which in turn fires up an RPC server implementing the
|
|
// LanguageRuntimeServer RPC endpoint.
|
|
func main() {
|
|
var tracing string
|
|
flag.StringVar(&tracing, "tracing", "", "Emit tracing to a Zipkin-compatible tracing endpoint")
|
|
|
|
// 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-python", "pulumi-language-python", tracing)
|
|
|
|
var pythonExec string
|
|
if givenExecutor == "" {
|
|
// By default, the -exec script is installed next to the language host.
|
|
thisPath, err := os.Executable()
|
|
if err != nil {
|
|
err = errors.Wrap(err, "could not determine current executable")
|
|
cmdutil.Exit(err)
|
|
}
|
|
|
|
pathExec := filepath.Join(filepath.Dir(thisPath), pythonDefaultExec)
|
|
if _, err = os.Stat(pathExec); os.IsNotExist(err) {
|
|
err = errors.Errorf("missing executor %s", pathExec)
|
|
cmdutil.Exit(err)
|
|
}
|
|
|
|
logging.V(3).Infof("language host identified executor from path: `%s`", pathExec)
|
|
pythonExec = pathExec
|
|
} else {
|
|
logging.V(3).Infof("language host asked to use specific executor: `%s`", givenExecutor)
|
|
pythonExec = 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(pythonExec, engineAddress, tracing)
|
|
pulumirpc.RegisterLanguageRuntimeServer(srv, host)
|
|
return 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"))
|
|
}
|
|
}
|
|
|
|
// pythonLanguageHost implements the LanguageRuntimeServer interface
|
|
// for use as an API endpoint.
|
|
type pythonLanguageHost struct {
|
|
exec string
|
|
engineAddress string
|
|
tracing string
|
|
}
|
|
|
|
func newLanguageHost(exec, engineAddress, tracing string) pulumirpc.LanguageRuntimeServer {
|
|
return &pythonLanguageHost{
|
|
exec: exec,
|
|
engineAddress: engineAddress,
|
|
tracing: tracing,
|
|
}
|
|
}
|
|
|
|
// GetRequiredPlugins computes the complete set of anticipated plugins required by a program.
|
|
func (host *pythonLanguageHost) GetRequiredPlugins(ctx context.Context,
|
|
req *pulumirpc.GetRequiredPluginsRequest) (*pulumirpc.GetRequiredPluginsResponse, error) {
|
|
// TODO: implement this.
|
|
return &pulumirpc.GetRequiredPluginsResponse{}, nil
|
|
}
|
|
|
|
// RPC endpoint for LanguageRuntimeServer::Run
|
|
func (host *pythonLanguageHost) Run(ctx context.Context, req *pulumirpc.RunRequest) (*pulumirpc.RunResponse, error) {
|
|
args := []string{host.exec}
|
|
args = append(args, host.constructArguments(req)...)
|
|
|
|
config, err := host.constructConfig(req)
|
|
if err != nil {
|
|
err = errors.Wrap(err, "failed to serialize configuration")
|
|
return nil, err
|
|
}
|
|
|
|
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
|
|
pythonCmd := os.Getenv("PULUMI_PYTHON_CMD")
|
|
if pythonCmd == "" {
|
|
// Look for "python3" by default. "python" usually refers to Python 2.7 on most distros.
|
|
pythonCmd = "python3"
|
|
}
|
|
|
|
// Look for the Python we intend to launch and emit an error if we can't find it. This is intended
|
|
// to catch people that don't have Python 3 installed.
|
|
pythonPath, err := exec.LookPath(pythonCmd)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"Failed to locate '%s' on your PATH. Have you installed Python 3.6 or greater?", pythonCmd)
|
|
}
|
|
|
|
cmd := exec.Command(pythonPath, args...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if config != "" {
|
|
cmd.Env = append(os.Environ(), pulumiConfigVar+"="+config)
|
|
}
|
|
if err := cmd.Run(); err != nil {
|
|
// Python does not explicitly flush standard out or standard error when exiting abnormally. For this reason, we
|
|
// need to explicitly flush our output streams so that, when we exit, the engine picks up the child Python
|
|
// process's stdout and stderr writes.
|
|
//
|
|
// This is especially crucial for Python because it is possible for the child Python process to crash very fast
|
|
// if Pulumi is misconfigured, so we must be sure to present a high-quality error message to the user.
|
|
contract.IgnoreError(os.Stdout.Sync())
|
|
contract.IgnoreError(os.Stderr.Sync())
|
|
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 {
|
|
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
|
|
}
|
|
|
|
// constructArguments constructs a command-line for `pulumi-language-python`
|
|
// by enumerating all of the optional and non-optional arguments present
|
|
// in a RunRequest.
|
|
func (host *pythonLanguageHost) constructArguments(req *pulumirpc.RunRequest) []string {
|
|
var args []string
|
|
maybeAppendArg := func(k, v string) {
|
|
if v != "" {
|
|
args = append(args, "--"+k, v)
|
|
}
|
|
}
|
|
|
|
maybeAppendArg("monitor", req.GetMonitorAddress())
|
|
maybeAppendArg("engine", host.engineAddress)
|
|
maybeAppendArg("project", req.GetProject())
|
|
maybeAppendArg("stack", req.GetStack())
|
|
maybeAppendArg("pwd", req.GetPwd())
|
|
maybeAppendArg("dry_run", fmt.Sprintf("%v", req.GetDryRun()))
|
|
maybeAppendArg("parallel", fmt.Sprint(req.GetParallel()))
|
|
maybeAppendArg("tracing", host.tracing)
|
|
|
|
// If no program is specified, just default to the current directory (which will invoke "__main__.py").
|
|
if req.GetProgram() == "" {
|
|
args = append(args, ".")
|
|
} else {
|
|
args = append(args, req.GetProgram())
|
|
}
|
|
|
|
args = append(args, req.GetArgs()...)
|
|
return args
|
|
}
|
|
|
|
// constructConfig json-serializes the configuration data given as part of a RunRequest.
|
|
func (host *pythonLanguageHost) 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 *pythonLanguageHost) GetPluginInfo(ctx context.Context, req *pbempty.Empty) (*pulumirpc.PluginInfo, error) {
|
|
return &pulumirpc.PluginInfo{
|
|
Version: version.Version,
|
|
}, nil
|
|
}
|