Go plugin acquisition (#4060)

These changes implement `GetRequiredPlugins` for Go using a registry
mechanism and an alternate entry point for `pulumi.Run`. Packages that
require plugins are expected to register themselves with the Pulumi SDK.
When `pulumi.Run` is used and the `PULUMI_PLUGINS` envvar is truthy, the
program will dump a JSON-encoded description of its required plugins to
stdout. The language host then uses this description to respond to
This commit is contained in:
Evan Boyle 2020-03-18 12:41:45 -07:00 committed by GitHub
parent 1354b17eb3
commit 8b46e71887
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 156 additions and 82 deletions

View file

@ -1,5 +1,8 @@
CHANGELOG
=========
## HEAD (Unreleased)
- Add support for plugin acquisition for Go programs
[#4060](https://github.com/pulumi/pulumi/pull/4060)
## HEAD (Unreleased)
- Update to Helm v3 in pulumi Docker image

View file

@ -730,7 +730,7 @@ func (pkg *pkgContext) genType(w io.Writer, obj *schema.ObjectType) {
pkg.genOutputTypes(w, obj, pkg.details(obj))
}
func (pkg *pkgContext) genInitFn(w io.Writer, types []*schema.ObjectType) {
func (pkg *pkgContext) genTypeRegistrations(w io.Writer, types []*schema.ObjectType) {
fmt.Fprintf(w, "func init() {\n")
for _, obj := range types {
name, details := pkg.tokenToType(obj.Token), pkg.details(obj)
@ -912,6 +912,12 @@ func (pkg *pkgContext) genConfig(w io.Writer, variables []*schema.Property) erro
return nil
}
func (pkg *pkgContext) genPackageRegistration(w io.Writer) {
fmt.Fprintf(w, "func init() {\n")
fmt.Fprintf(w, "\tpulumi.RegisterPackage(pulumi.PackageInfo{Name:\"%s\", Version:\"%s\"})\n", pkg.pkg.Name, pkg.pkg.Version.String())
fmt.Fprintf(w, "}\n")
}
// GoInfo holds information required to generate the Go SDK from a schema.
type GoInfo struct {
// Base path for package imports
@ -1058,7 +1064,7 @@ func GeneratePackage(tool string, pkg *schema.Package) (map[string][]byte, error
files[relPath] = []byte(contents)
}
name := pkg.Name
name, registerPackage := pkg.Name, pkg.Provider != nil
for _, mod := range pkgMods {
pkg := packages[mod]
@ -1130,11 +1136,21 @@ func GeneratePackage(tool string, pkg *schema.Package) (map[string][]byte, error
pkg.genType(buffer, t)
}
pkg.genInitFn(buffer, pkg.types)
pkg.genTypeRegistrations(buffer, pkg.types)
setFile(path.Join(mod, "pulumiTypes.go"), buffer.String())
}
// Package registration
if registerPackage {
buffer := &bytes.Buffer{}
pkg.genHeader(buffer, []string{"github.com/pulumi/pulumi/sdk/go/pulumi"}, nil)
pkg.genPackageRegistration(buffer)
setFile(path.Join(mod, "pulumiManifest.go"), buffer.String())
}
// Utilities
if pkg.needsUtils {
buffer := &bytes.Buffer{}

View file

@ -37,6 +37,76 @@ import (
pulumirpc "github.com/pulumi/pulumi/sdk/proto/go"
)
const unableToFindProgramTemplate = "unable to find program: %s"
// findExecutable attempts to find the needed executable in various locations on the
// filesystem, eventually resorting to searching in $PATH.
func findExecutable(program string) (string, error) {
if runtime.GOOS == "windows" {
program = fmt.Sprintf("%s.exe", program)
}
// look in the same directory
cwd, err := os.Getwd()
if err != nil {
return "", errors.Wrap(err, "unable to get current working directory")
}
cwdProgram := filepath.Join(cwd, program)
if fileInfo, err := os.Stat(cwdProgram); !os.IsNotExist(err) && !fileInfo.Mode().IsDir() {
logging.V(5).Infof("program %s found in CWD", program)
return cwdProgram, nil
}
// look in $GOPATH/bin
if goPath := os.Getenv("GOPATH"); len(goPath) > 0 {
goPathProgram := filepath.Join(goPath, "bin", program)
if fileInfo, err := os.Stat(goPathProgram); !os.IsNotExist(err) && !fileInfo.Mode().IsDir() {
logging.V(5).Infof("program %s found in $GOPATH/bin", program)
return goPathProgram, nil
}
}
// look in the $PATH somewhere
if fullPath, err := exec.LookPath(program); err == nil {
logging.V(5).Infof("program %s found in $PATH", program)
return fullPath, nil
}
return "", errors.Errorf(unableToFindProgramTemplate, program)
}
func findProgram(project string) (*exec.Cmd, error) {
// The program to execute is simply the name of the project. This ensures good Go toolability, whereby
// you can simply run `go install .` to build a Pulumi program prior to running it, among other benefits.
// For ease of use, if we don't find a pre-built program, we attempt to invoke via 'go run' on behalf of the user.
program, err := findExecutable(project)
if err == nil {
return exec.Command(program), nil
}
const message = "problem executing program (could not run language executor)"
if err.Error() == fmt.Sprintf(unableToFindProgramTemplate, project) {
logging.V(5).Infof("Unable to find program %s in $PATH, attempting invocation via 'go run'", program)
program, err = findExecutable("go")
}
if err != nil {
return nil, errors.Wrap(err, message)
}
// Fall back to 'go run' style execution
cwd, err := os.Getwd()
if err != nil {
return nil, errors.Wrap(err, "unable to get current working directory")
}
goFileSearchPattern := filepath.Join(cwd, "*.go")
if matches, err := filepath.Glob(goFileSearchPattern); err != nil || len(matches) == 0 {
return nil, errors.Errorf("Failed to find go files for 'go run' matching %s", goFileSearchPattern)
}
return exec.Command(program, "run", cwd), nil
}
// Launches the language host, which in turn fires up an RPC server implementing the LanguageRuntimeServer endpoint.
func main() {
var tracing string
@ -90,46 +160,34 @@ func newLanguageHost(engineAddress, tracing string) pulumirpc.LanguageRuntimeSer
// GetRequiredPlugins computes the complete set of anticipated plugins required by a program.
func (host *goLanguageHost) GetRequiredPlugins(ctx context.Context,
req *pulumirpc.GetRequiredPluginsRequest) (*pulumirpc.GetRequiredPluginsResponse, error) {
return &pulumirpc.GetRequiredPluginsResponse{}, nil
}
const unableToFindProgramTemplate = "unable to find program: %s"
// findProgram attempts to find the needed program in various locations on the
// filesystem, eventually resorting to searching in $PATH.
func findProgram(program string) (string, error) {
if runtime.GOOS == "windows" {
program = fmt.Sprintf("%s.exe", program)
}
// look in the same directory
cwd, err := os.Getwd()
cmd, err := findProgram(req.GetProject())
if err != nil {
return "", errors.Wrap(err, "unable to get current working directory")
return nil, errors.Wrap(err, "failed to find program")
}
cwdProgram := filepath.Join(cwd, program)
if fileInfo, err := os.Stat(cwdProgram); !os.IsNotExist(err) && !fileInfo.Mode().IsDir() {
logging.V(5).Infof("program %s found in CWD", program)
return cwdProgram, nil
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "PULUMI_PLUGINS=true")
stdout, err := cmd.Output()
if err != nil {
return nil, errors.Wrap(err, "failed to execute program cmd")
}
// look in $GOPATH/bin
if goPath := os.Getenv("GOPATH"); len(goPath) > 0 {
goPathProgram := filepath.Join(goPath, "bin", program)
if fileInfo, err := os.Stat(goPathProgram); !os.IsNotExist(err) && !fileInfo.Mode().IsDir() {
logging.V(5).Infof("program %s found in $GOPATH/bin", program)
return goPathProgram, nil
}
var infos map[string][]pulumi.PackageInfo
if err := json.Unmarshal(stdout, &infos); err != nil {
return nil, errors.Wrap(err, "failed to unmarshal result")
}
// look in the $PATH somewhere
if fullPath, err := exec.LookPath(program); err == nil {
logging.V(5).Infof("program %s found in $PATH", program)
return fullPath, nil
var plugins []*pulumirpc.PluginDependency
for _, info := range infos["plugins"] {
plugins = append(plugins, &pulumirpc.PluginDependency{
Name: info.Name,
Kind: "resource",
Version: info.Version,
Server: info.Server,
})
}
return "", errors.Errorf(unableToFindProgramTemplate, program)
return &pulumirpc.GetRequiredPluginsResponse{Plugins: plugins}, nil
}
// RPC endpoint for LanguageRuntimeServer::Run
@ -141,54 +199,14 @@ func (host *goLanguageHost) Run(ctx context.Context, req *pulumirpc.RunRequest)
return nil, errors.Wrap(err, "failed to prepare environment")
}
// by default we try to run a named executable on the path, but we will fallback to 'go run' style execution
goRunInvoke := false
// The program to execute is simply the name of the project. This ensures good Go toolability, whereby
// you can simply run `go install .` to build a Pulumi program prior to running it, among other benefits.
// For ease of use, if we don't find a pre-built program, we attempt to invoke via 'go run' on behalf of the user.
program, err := findProgram(req.GetProject())
cmd, err := findProgram(req.GetProject())
if err != nil {
const message = "problem executing program (could not run language executor)"
if err.Error() == fmt.Sprintf(unableToFindProgramTemplate, req.GetProject()) {
logging.V(5).Infof("Unable to find program %s in $PATH, attempting invocation via 'go run'", program)
program, err = findProgram("go")
if err != nil {
return nil, errors.Wrap(err, message)
}
goRunInvoke = true
} else {
return nil, errors.Wrap(err, message)
}
return nil, err
}
logging.V(5).Infof("language host launching process: %s", program)
// Now simply spawn a process to execute the requested program, wiring up stdout/stderr directly.
var errResult string
var cmd *exec.Cmd
if goRunInvoke {
cwd, err := os.Getwd()
if err != nil {
return nil, errors.Wrap(err, "unable to get current working directory")
}
goFileSearchPattern := filepath.Join(cwd, "*.go")
if matches, err := filepath.Glob(goFileSearchPattern); err != nil || len(matches) == 0 {
return nil, errors.Errorf("Failed to find go files for 'go run' matching %s", goFileSearchPattern)
}
args := []string{"run", cwd}
// go run $cwd
cmd = exec.Command(program, args...)
} else {
cmd = exec.Command(program)
}
cmd.Env = env
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
var errResult string
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

View file

@ -27,6 +27,8 @@ import (
"github.com/pulumi/pulumi/pkg/util/contract"
)
var ErrPlugins = errors.New("pulumi: plugins requested")
// A RunOption is used to control the behavior of Run and RunErr.
type RunOption func(*RunInfo)
@ -35,8 +37,13 @@ type RunOption func(*RunInfo)
// If the program fails, the process will be terminated and the function will not return.
func Run(body RunFunc, opts ...RunOption) {
if err := RunErr(body, opts...); err != nil {
fmt.Fprintf(os.Stderr, "error: program failed: %v\n", err)
os.Exit(1)
if err != ErrPlugins {
fmt.Fprintf(os.Stderr, "error: program failed: %v\n", err)
os.Exit(1)
}
printRequiredPlugins()
os.Exit(0)
}
}
@ -46,6 +53,9 @@ func RunErr(body RunFunc, opts ...RunOption) error {
// Parse the info out of environment variables. This is a lame contract with the caller, but helps to keep
// boilerplate to a minimum in the average Pulumi Go program.
info := getEnvInfo()
if info.getPlugins {
return ErrPlugins
}
for _, o := range opts {
o(&info)
@ -121,6 +131,7 @@ type RunInfo struct {
MonitorAddr string
EngineAddr string
Mocks MockResourceMonitor
getPlugins bool
}
// getEnvInfo reads various program information from the process environment.
@ -128,6 +139,7 @@ func getEnvInfo() RunInfo {
// Most of the variables are just strings, and we can read them directly. A few of them require more parsing.
parallel, _ := strconv.Atoi(os.Getenv(EnvParallel))
dryRun, _ := strconv.ParseBool(os.Getenv(EnvDryRun))
getPlugins, _ := strconv.ParseBool(os.Getenv(envPlugins))
var config map[string]string
if cfg := os.Getenv(EnvConfig); cfg != "" {
@ -142,6 +154,7 @@ func getEnvInfo() RunInfo {
DryRun: dryRun,
MonitorAddr: os.Getenv(EnvMonitor),
EngineAddr: os.Getenv(EnvEngine),
getPlugins: getPlugins,
}
}
@ -160,4 +173,28 @@ const (
EnvMonitor = "PULUMI_MONITOR"
// EnvEngine is the envvar used to read the current Pulumi engine RPC address.
EnvEngine = "PULUMI_ENGINE"
// envPlugins is the envvar used to request that the Pulumi program print its set of required plugins and exit.
envPlugins = "PULUMI_PLUGINS"
)
type PackageInfo struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
Server string `json:"server,omitempty"`
}
var packageRegistry = map[PackageInfo]struct{}{}
func RegisterPackage(info PackageInfo) {
packageRegistry[info] = struct{}{}
}
func printRequiredPlugins() {
plugins := []PackageInfo{}
for info := range packageRegistry {
plugins = append(plugins, info)
}
err := json.NewEncoder(os.Stdout).Encode(map[string]interface{}{"plugins": plugins})
contract.IgnoreError(err)
}