mirror of https://github.com/pulumi/pulumi.git
447 lines
13 KiB
Go
447 lines
13 KiB
Go
// Copyright 2016-2021, 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.
|
|
|
|
package provider
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"go/ast"
|
|
"go/doc"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/pkg/errors"
|
|
|
|
"github.com/pulumi/pulumi/pkg/v3/codegen/schema"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/pulumi/provider"
|
|
pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go"
|
|
)
|
|
|
|
var contextType = reflect.TypeOf((*pulumi.Context)(nil))
|
|
var errorType = reflect.TypeOf((*error)(nil)).Elem()
|
|
var resourceType = reflect.TypeOf((*pulumi.Resource)(nil)).Elem()
|
|
var componentResourceType = reflect.TypeOf((*pulumi.ComponentResource)(nil)).Elem()
|
|
|
|
type componentInfo struct {
|
|
factory reflect.Value
|
|
argsType reflect.Type
|
|
resourceType reflect.Type
|
|
inputs map[string]string
|
|
outputs map[string]string
|
|
}
|
|
|
|
// ComponentMain is an entrypoint for a resource provider plugin that implements `Construct` for component resources.
|
|
// Using it isn't required but can cut down significantly on the amount of boilerplate necessary to fire up a new
|
|
// resource provider for components.
|
|
func ComponentMainAuto(name, version string, componentFactories ...interface{}) error {
|
|
// Maps component token to its construct function.
|
|
components := make(map[string]componentInfo)
|
|
|
|
for i, factory := range componentFactories {
|
|
fv := reflect.ValueOf(factory)
|
|
if fv.Kind() != reflect.Func {
|
|
return errors.Errorf("componentFactories[%v] not a function", i)
|
|
}
|
|
|
|
ft := fv.Type()
|
|
|
|
// TODO handle these cases:
|
|
// ft.NumIn() == 2: ctx, name
|
|
// ft.NumIn() == 3: ctx, name, ...options or args
|
|
// ft.NumIn() == 4: ctx, name, args, ...options
|
|
if ft.NumIn() != 4 {
|
|
panic(errors.New("expected 4 inputs"))
|
|
}
|
|
if !ft.In(0).AssignableTo(contextType) {
|
|
panic(errors.Errorf("first argument must be %v", contextType))
|
|
}
|
|
if ft.In(1).Kind() != reflect.String {
|
|
panic(errors.New("second argument must be a string"))
|
|
}
|
|
|
|
argsType := ft.In(2)
|
|
if argsType.Kind() == reflect.Ptr {
|
|
argsType = argsType.Elem()
|
|
}
|
|
|
|
if ft.NumOut() != 2 {
|
|
panic(errors.New("expected 2 return values"))
|
|
}
|
|
if !ft.Out(0).AssignableTo(componentResourceType) {
|
|
panic(errors.New("first return type must be assignable to pulumi.ComponentResource"))
|
|
}
|
|
if !ft.Out(1).AssignableTo(errorType) {
|
|
panic(errors.New("second return type must be assignable to error"))
|
|
}
|
|
|
|
resourceType := ft.Out(0)
|
|
if resourceType.Kind() == reflect.Ptr {
|
|
resourceType = resourceType.Elem()
|
|
}
|
|
|
|
// TODO error if the pkg part of the token doesn't match the specified package name?
|
|
token, inputs, outputs, err := tokenAndDescriptions(fv, argsType.Name(), resourceType.Name())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
components[token] = componentInfo{
|
|
factory: fv,
|
|
argsType: argsType,
|
|
resourceType: resourceType,
|
|
inputs: inputs,
|
|
outputs: outputs,
|
|
}
|
|
}
|
|
|
|
pkgSpec := genSchema(name, components)
|
|
schemaJSON, err := json.MarshalIndent(pkgSpec, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var emitSchema bool
|
|
for _, arg := range os.Args[1:] {
|
|
if arg == "-schema" {
|
|
emitSchema = true
|
|
break
|
|
}
|
|
}
|
|
if emitSchema {
|
|
fmt.Println(string(schemaJSON))
|
|
return nil
|
|
}
|
|
|
|
construct := func(ctx *pulumi.Context, typ, name string, inputs provider.ConstructInputs,
|
|
options pulumi.ResourceOption) (*provider.ConstructResult, error) {
|
|
info, ok := components[typ]
|
|
if !ok {
|
|
return nil, errors.Errorf("unknown resource type %s", typ)
|
|
}
|
|
|
|
// Copy the raw inputs to args. `inputs.CopyTo` uses the types and `pulumi:` tags
|
|
// on the struct's fields to convert the raw values to the appropriate Input types.
|
|
args := reflect.New(info.argsType)
|
|
if err := inputs.CopyTo(args.Interface()); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Create the component resource.
|
|
in := []reflect.Value{reflect.ValueOf(ctx), reflect.ValueOf(name), args, reflect.ValueOf(options)}
|
|
out := info.factory.Call(in)
|
|
contract.Assertf(len(out) == 2, "expected two results")
|
|
if err, ok := out[1].Interface().(error); ok && err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Return the component resource's URN and state. `NewConstructResult` automatically sets the
|
|
// ConstructResult's state based on resource struct fields tagged with `pulumi:` tags with a value
|
|
// that is convertible to `pulumi.Input`.
|
|
return provider.NewConstructResult(out[0].Interface().(pulumi.ComponentResource))
|
|
}
|
|
|
|
return main(name, func(host *HostClient) (pulumirpc.ResourceProviderServer, error) {
|
|
return &componentProvider{
|
|
host: host,
|
|
name: name,
|
|
version: version,
|
|
schema: schemaJSON,
|
|
construct: construct,
|
|
}, nil
|
|
}, true /*schema*/)
|
|
}
|
|
|
|
func genSchema(name string, components map[string]componentInfo) schema.PackageSpec {
|
|
// TODO provide a way to specify more metadata.
|
|
pkg := schema.PackageSpec{
|
|
Name: name,
|
|
// TODO be smarter about these based on the types used, and/or provide a way for users to override.
|
|
Language: map[string]json.RawMessage{
|
|
"csharp": rawMessage(map[string]interface{}{
|
|
"packageReferences": map[string]string{
|
|
"Pulumi": "3.*",
|
|
"Pulumi.Aws": "4.*",
|
|
},
|
|
}),
|
|
"nodejs": rawMessage(map[string]interface{}{
|
|
"dependencies": map[string]string{
|
|
"@pulumi/aws": "^4.0.0",
|
|
},
|
|
"devDependencies": map[string]string{
|
|
"typescript": "^3.7.0",
|
|
},
|
|
}),
|
|
"python": rawMessage(map[string]interface{}{
|
|
"requires": map[string]string{
|
|
"pulumi": ">=3.0.0,<4.0.0",
|
|
"pulumi-aws": ">=4.0.0,<5.0.0",
|
|
},
|
|
"readme": "",
|
|
}),
|
|
"go": rawMessage(map[string]interface{}{
|
|
"generateResourceContainerTypes": true,
|
|
}),
|
|
},
|
|
}
|
|
|
|
// Add components.
|
|
if len(components) > 0 {
|
|
pkg.Resources = make(map[string]schema.ResourceSpec)
|
|
}
|
|
for token, component := range components {
|
|
pkg.Resources[token] = genResourceSpec(component)
|
|
}
|
|
return pkg
|
|
}
|
|
|
|
func genResourceSpec(component componentInfo) schema.ResourceSpec {
|
|
spec := schema.ResourceSpec{
|
|
IsComponent: true,
|
|
ObjectTypeSpec: schema.ObjectTypeSpec{
|
|
Description: component.outputs[""],
|
|
},
|
|
}
|
|
|
|
// Inputs
|
|
var requiredInputs []string
|
|
inputs := make(map[string]schema.PropertySpec)
|
|
for i := 0; i < component.argsType.NumField(); i++ {
|
|
field := component.argsType.Field(i)
|
|
tag := field.Tag.Get("pulumi")
|
|
if tag == "" {
|
|
continue
|
|
}
|
|
|
|
fieldType := field.Type
|
|
if fieldType.Kind() == reflect.Ptr {
|
|
fieldType = fieldType.Elem()
|
|
} else {
|
|
requiredInputs = append(requiredInputs, tag)
|
|
}
|
|
|
|
inputs[tag] = schema.PropertySpec{
|
|
TypeSpec: genTypeSpec(fieldType, true /*input*/),
|
|
Description: component.inputs[field.Name],
|
|
}
|
|
}
|
|
spec.InputProperties = inputs
|
|
spec.RequiredInputs = requiredInputs
|
|
|
|
// Outputs
|
|
var required []string
|
|
outputs := make(map[string]schema.PropertySpec)
|
|
for i := 0; i < component.resourceType.NumField(); i++ {
|
|
field := component.resourceType.Field(i)
|
|
tag := field.Tag.Get("pulumi")
|
|
if tag == "" {
|
|
continue
|
|
}
|
|
|
|
fieldType := field.Type
|
|
if fieldType.Kind() == reflect.Ptr {
|
|
// If it's a resource output, it'll be a pointer, but we'll consider it to be always populated.
|
|
if fieldType.AssignableTo(resourceType) {
|
|
required = append(required, tag)
|
|
}
|
|
fieldType = fieldType.Elem()
|
|
} else {
|
|
required = append(required, tag)
|
|
}
|
|
|
|
outputs[tag] = schema.PropertySpec{
|
|
TypeSpec: genTypeSpec(fieldType, false /*input*/),
|
|
Description: component.outputs[field.Name],
|
|
}
|
|
}
|
|
spec.ObjectTypeSpec.Properties = outputs
|
|
spec.ObjectTypeSpec.Required = required
|
|
|
|
return spec
|
|
}
|
|
|
|
func genTypeSpec(fieldType reflect.Type, input bool) schema.TypeSpec {
|
|
// github.com/pulumi/pulumi-aws/sdk/v4/go/aws/s3
|
|
// TODO figure out a way to determine this for non-pulumi packages and atypical import paths.
|
|
if reflect.PtrTo(fieldType).AssignableTo(resourceType) {
|
|
components := strings.Split(fieldType.PkgPath(), "/")
|
|
if len(components) >= 3 &&
|
|
components[0] == "github.com" &&
|
|
components[1] == "pulumi" &&
|
|
strings.HasPrefix(components[2], "pulumi-") {
|
|
pkg := strings.TrimPrefix(components[2], "pulumi-")
|
|
if len(components) >= 4 && components[3] == "sdk" {
|
|
components = components[4:]
|
|
version := "v1"
|
|
if strings.HasPrefix(components[0], "v") {
|
|
version = components[0]
|
|
components = components[1:]
|
|
}
|
|
if len(components) > 0 && components[0] == "go" {
|
|
components = components[1:]
|
|
}
|
|
if len(components) > 0 && components[0] == pkg {
|
|
components = components[1:]
|
|
}
|
|
|
|
// Turn into: /aws/v4.0.0/schema.json#/resources/aws:s3%2Fbucket:Bucket
|
|
ref := fmt.Sprintf("/%s/%s.0.0/schema.json#/resources/%s:%s%s:%s", pkg, version, pkg, strings.Join(components, "%2F"), "%2F"+camel(fieldType.Name()), fieldType.Name())
|
|
return schema.TypeSpec{Ref: ref}
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
// TODO other types
|
|
switch fieldType.Name() {
|
|
// number?
|
|
case "int", "IntInput", "IntOutput":
|
|
return schema.TypeSpec{Type: "integer"}
|
|
case "bool", "BoolInput", "BoolOutput":
|
|
return schema.TypeSpec{Type: "boolean"}
|
|
case "string", "StringInput", "StringOutput":
|
|
fallthrough
|
|
default:
|
|
return schema.TypeSpec{Type: "string"}
|
|
}
|
|
}
|
|
|
|
// tokenAndDescriptions gets the token from the pulumi comment directive on the resoure type struct, and
|
|
// comments for the args and resource types.
|
|
func tokenAndDescriptions(fv reflect.Value, argsTypeName, resourceTypeName string) (string, map[string]string,
|
|
map[string]string, error) {
|
|
contract.Assertf(fv.Kind() == reflect.Func, "fv not a function")
|
|
|
|
// Determine the source file of the function. We'll look for the args and resource structs
|
|
// in the same file.
|
|
filename, _ := runtime.FuncForPC(fv.Pointer()).FileLine(0)
|
|
fset := token.NewFileSet()
|
|
|
|
// Parse the file.
|
|
parsed, err := parser.ParseFile(fset, filename, nil, parser.ParseComments)
|
|
if err != nil {
|
|
return "", nil, nil, err
|
|
}
|
|
|
|
pkg := &ast.Package{
|
|
Name: "Any",
|
|
Files: map[string]*ast.File{filename: parsed},
|
|
}
|
|
|
|
// TODO don't bother using `doc` -- just look at the AST directly.
|
|
|
|
importPath := filepath.Base(filename)
|
|
funcDoc := doc.New(pkg, importPath, doc.AllDecls|doc.PreserveAST)
|
|
|
|
var token string
|
|
inputs, outputs := make(map[string]string), make(map[string]string)
|
|
var sawInputs, sawOutputs bool
|
|
for _, typ := range funcDoc.Types {
|
|
switch typ.Name {
|
|
case argsTypeName:
|
|
getDescriptions(typ, inputs)
|
|
sawInputs = true
|
|
case resourceTypeName:
|
|
token, err = getTokenFromPulumiDirective(typ)
|
|
if err != nil {
|
|
return "", nil, nil, err
|
|
}
|
|
getDescriptions(typ, outputs)
|
|
sawOutputs = true
|
|
default:
|
|
if sawInputs && sawOutputs {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO provide some kind of warning if couldn't find args/resource types?
|
|
|
|
return token, inputs, outputs, nil
|
|
}
|
|
|
|
func getDescriptions(typ *doc.Type, descriptions map[string]string) {
|
|
if typ.Doc != "" {
|
|
descriptions[""] = strings.TrimSpace(typ.Doc)
|
|
}
|
|
if len(typ.Decl.Specs) != 1 {
|
|
return
|
|
}
|
|
typeSpec, ok := typ.Decl.Specs[0].(*ast.TypeSpec)
|
|
if !ok {
|
|
return
|
|
}
|
|
structType, ok := typeSpec.Type.(*ast.StructType)
|
|
if !ok {
|
|
return
|
|
}
|
|
if structType.Fields == nil {
|
|
return
|
|
}
|
|
for _, field := range structType.Fields.List {
|
|
if len(field.Names) != 1 {
|
|
continue
|
|
}
|
|
doc := field.Doc.Text()
|
|
if doc != "" {
|
|
descriptions[field.Names[0].Name] = strings.TrimSpace(doc)
|
|
}
|
|
}
|
|
}
|
|
|
|
func getTokenFromPulumiDirective(typ *doc.Type) (string, error) {
|
|
const errorMsg = "resource type missing token; add a //pulumi:package:module:resource directive to the struct"
|
|
if typ.Decl.Doc == nil || typ.Decl.Doc.List == nil {
|
|
return "", errors.New(errorMsg)
|
|
}
|
|
for _, comment := range typ.Decl.Doc.List {
|
|
for _, prefix := range []string{"//pulumi:", "// pulumi:"} {
|
|
if strings.HasPrefix(comment.Text, prefix) {
|
|
return strings.TrimPrefix(comment.Text, prefix), nil
|
|
}
|
|
}
|
|
}
|
|
return "", errors.New(errorMsg)
|
|
}
|
|
|
|
func rawMessage(v interface{}) json.RawMessage {
|
|
bytes, err := json.Marshal(v)
|
|
contract.Assert(err == nil)
|
|
return bytes
|
|
}
|
|
|
|
func camel(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
runes := []rune(s)
|
|
res := make([]rune, 0, len(runes))
|
|
for i, r := range runes {
|
|
if unicode.IsLower(r) {
|
|
res = append(res, runes[i:]...)
|
|
break
|
|
}
|
|
res = append(res, unicode.ToLower(r))
|
|
}
|
|
return string(res)
|
|
}
|