mirror of https://github.com/pulumi/pulumi.git
366 lines
12 KiB
Go
366 lines
12 KiB
Go
// Copyright 2016-2018, Pulumi Corporation. All rights reserved.
|
|
|
|
// pulumi-language-nodejs serves as the "language host" for Pulumi
|
|
// programs written in NodeJS. 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-nodejs-exec`. This script is written in the hosted
|
|
// language (in this case, node) 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-nodejs-exec` and forwarding the RPC request arguments
|
|
// to the command-line.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"github.com/golang/glog"
|
|
pbempty "github.com/golang/protobuf/ptypes/empty"
|
|
"github.com/pkg/errors"
|
|
"github.com/pulumi/pulumi/pkg/resource/config"
|
|
"github.com/pulumi/pulumi/pkg/util/cmdutil"
|
|
"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-nodejs) plus this suffix.
|
|
nodeExecSuffix = "-exec" // the exec shim for Pulumi to run Node 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
|
|
var givenExecutor 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 is used specifically
|
|
// in run.spec.ts to work around some unfortunate Node module loading behavior.
|
|
flag.StringVar(&givenExecutor, "use-executor", "",
|
|
"Use the given program as the executor instead of looking for one on PATH")
|
|
|
|
flag.Parse()
|
|
args := flag.Args()
|
|
cmdutil.InitLogging(false, 0, false)
|
|
cmdutil.InitTracing(os.Args[0], tracing)
|
|
var nodeExec string
|
|
if givenExecutor == "" {
|
|
// The -exec binary is the same name as the current language host, except that we must trim off
|
|
// the file extension (if any) and then append -exec to it.
|
|
bin := os.Args[0]
|
|
if ext := filepath.Ext(bin); ext != "" {
|
|
bin = bin[:len(bin)-len(ext)]
|
|
}
|
|
bin += nodeExecSuffix
|
|
pathExec, err := exec.LookPath(bin)
|
|
if err != nil {
|
|
err = errors.Wrapf(err, "could not find `%s` on the $PATH", bin)
|
|
cmdutil.Exit(err)
|
|
}
|
|
|
|
glog.V(3).Infof("language host identified executor from path: `%s`", pathExec)
|
|
nodeExec = pathExec
|
|
} else {
|
|
glog.V(3).Infof("language host asked to use specific executor: `%s`", givenExecutor)
|
|
nodeExec = 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(nodeExec, 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"))
|
|
}
|
|
}
|
|
|
|
// nodeLanguageHost implements the LanguageRuntimeServer interface
|
|
// for use as an API endpoint.
|
|
type nodeLanguageHost struct {
|
|
exec string
|
|
engineAddress string
|
|
tracing string
|
|
}
|
|
|
|
func newLanguageHost(exec, engineAddress, tracing string) pulumirpc.LanguageRuntimeServer {
|
|
return &nodeLanguageHost{
|
|
exec: exec,
|
|
engineAddress: engineAddress,
|
|
tracing: tracing,
|
|
}
|
|
}
|
|
|
|
// GetRequiredPlugins computes the complete set of anticipated plugins required by a program.
|
|
func (host *nodeLanguageHost) GetRequiredPlugins(ctx context.Context,
|
|
req *pulumirpc.GetRequiredPluginsRequest) (*pulumirpc.GetRequiredPluginsResponse, error) {
|
|
// To get the plugins required by a program, find all node_modules/ packages that contain { "pulumi": true }
|
|
// inside of their packacge.json files. We begin this search in the same directory that contains the project.
|
|
// It's possible that a developer would do a `require("../../elsewhere")` and that we'd miss this as a
|
|
// dependency, however the solution for that is simple: install the package in the project root.
|
|
plugins, err := getPluginsFromDir(req.GetProgram(), false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &pulumirpc.GetRequiredPluginsResponse{
|
|
Plugins: plugins,
|
|
}, nil
|
|
}
|
|
|
|
// getPluginsFromDir enumerates all node_modules/ directories, deeply, and returns the fully concatenated results.
|
|
func getPluginsFromDir(dir string, inNodeModules bool) ([]*pulumirpc.PluginDependency, error) {
|
|
files, err := ioutil.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "reading plugin dir %s", dir)
|
|
}
|
|
|
|
var plugins []*pulumirpc.PluginDependency
|
|
for _, file := range files {
|
|
name := file.Name()
|
|
curr := filepath.Join(dir, name)
|
|
|
|
// Re-stat the directory, in case it is a symlink.
|
|
file, err = os.Stat(curr)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "re-statting file %s", curr)
|
|
}
|
|
if file.IsDir() {
|
|
// if a directory, recurse.
|
|
more, err := getPluginsFromDir(curr, inNodeModules || filepath.Base(dir) == "node_modules")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
plugins = append(plugins, more...)
|
|
} else if inNodeModules && name == "package.json" {
|
|
// if a package.json file within a node_modules package, parse it, and see if it's a source of plugins.
|
|
b, err := ioutil.ReadFile(curr)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "reading package.json %s", curr)
|
|
}
|
|
ok, name, version, err := getPackageInfo(b)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "unmarshaling package.json %s", curr)
|
|
} else if ok {
|
|
plugins = append(plugins, &pulumirpc.PluginDependency{
|
|
Name: name,
|
|
Kind: "resource",
|
|
Version: version,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return plugins, nil
|
|
}
|
|
|
|
// packageJSON is the minimal amount of package.json information we care about.
|
|
type packageJSON struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
Pulumi struct {
|
|
Resource bool `json:"resource"`
|
|
} `json:"pulumi"`
|
|
}
|
|
|
|
// getPackageInfo returns a bool indicating whether the given package.json package has an associated Pulumi
|
|
// resource provider plugin. If it does, two strings are returned, the plugin name, and its semantic version.
|
|
func getPackageInfo(b []byte) (bool, string, string, error) {
|
|
var info packageJSON
|
|
if err := json.Unmarshal(b, &info); err != nil {
|
|
return false, "", "", err
|
|
}
|
|
|
|
if info.Pulumi.Resource {
|
|
name, err := getPluginName(info)
|
|
if err != nil {
|
|
return false, "", "", err
|
|
}
|
|
version, err := getPluginVersion(info)
|
|
if err != nil {
|
|
return false, "", "", err
|
|
}
|
|
return true, name, version, nil
|
|
}
|
|
|
|
return false, "", "", nil
|
|
}
|
|
|
|
// getPluginName takes a parsed package.json file and returns the corresponding Pulumi plugin name.
|
|
func getPluginName(info packageJSON) (string, error) {
|
|
name := info.Name
|
|
if name == "" {
|
|
return "", errors.New("missing expected \"name\" property")
|
|
}
|
|
|
|
// If the name has a @pulumi scope, we will just use its simple name. Otherwise, we use the fullly scoped name.
|
|
// We do trim the leading @, however, since Pulumi resource providers do not use the same NPM convention.
|
|
if strings.Index(name, "@pulumi/") == 0 {
|
|
return name[strings.IndexRune(name, '/')+1:], nil
|
|
}
|
|
if strings.IndexRune(name, '@') == 0 {
|
|
return name[1:], nil
|
|
}
|
|
return name, nil
|
|
}
|
|
|
|
// getPluginVersion takes a parsed package.json file and returns the semantic version of the Pulumi plugin.
|
|
func getPluginVersion(info packageJSON) (string, error) {
|
|
version := info.Version
|
|
if version == "" {
|
|
return "", errors.New("Missing expected \"version\" property")
|
|
}
|
|
if strings.IndexRune(version, 'v') != 0 {
|
|
return fmt.Sprintf("v%s", version), nil
|
|
}
|
|
return version, nil
|
|
}
|
|
|
|
// RPC endpoint for LanguageRuntimeServer::Run
|
|
func (host *nodeLanguageHost) Run(ctx context.Context, req *pulumirpc.RunRequest) (*pulumirpc.RunResponse, error) {
|
|
args := host.constructArguments(req)
|
|
config, err := host.constructConfig(req)
|
|
if err != nil {
|
|
err = errors.Wrap(err, "failed to serialize configuration")
|
|
return nil, err
|
|
}
|
|
|
|
if glog.V(5) {
|
|
commandStr := strings.Join(args, " ")
|
|
glog.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 = append(os.Environ(), pulumiConfigVar+"="+string(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 {
|
|
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-nodejs`
|
|
// by enumerating all of the optional and non-optional arguments present
|
|
// in a RunRequest.
|
|
func (host *nodeLanguageHost) 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())
|
|
if req.GetDryRun() {
|
|
args = append(args, "--dry-run")
|
|
}
|
|
|
|
maybeAppendArg("parallel", fmt.Sprint(req.GetParallel()))
|
|
maybeAppendArg("tracing", host.tracing)
|
|
if req.GetProgram() == "" {
|
|
// If the program path is empty, just use "."; this will cause Node to try to load the default module
|
|
// file, by default ./index.js, but possibly overridden in the "main" element inside of package.json.
|
|
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 *nodeLanguageHost) constructConfig(req *pulumirpc.RunRequest) (string, error) {
|
|
configMap := req.GetConfig()
|
|
if configMap == nil {
|
|
return "{}", nil
|
|
}
|
|
|
|
// While we transition from the old format for config keys (<package>:config:<name> to <package>:<name>), we want
|
|
// to support the newest version of the langhost running older packages, so the config bag we present to them looks
|
|
// like the old world. Newer versions of the @pulumi/pulumi package handle both formats and when we stop supporting
|
|
// older versions, we can remove this code.
|
|
transformedConfig := make(map[string]string, len(configMap))
|
|
for k, v := range configMap {
|
|
pk, err := config.ParseKey(k)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
transformedConfig[pk.Namespace()+":config:"+pk.Name()] = v
|
|
}
|
|
|
|
configJSON, err := json.Marshal(transformedConfig)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return string(configJSON), nil
|
|
}
|
|
|
|
func (host *nodeLanguageHost) GetPluginInfo(ctx context.Context, req *pbempty.Empty) (*pulumirpc.PluginInfo, error) {
|
|
return &pulumirpc.PluginInfo{
|
|
Version: version.Version,
|
|
}, nil
|
|
}
|