command/jsonprovider: export providers schemas to json (#20446)

* command/jsonprovider: a new package for exporting providers schemas as JSON
This commit is contained in:
Kristin Laemmert 2019-02-25 13:32:47 -08:00 committed by GitHub
parent 2b9e2b4c2b
commit 16823f43de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 790 additions and 0 deletions

View File

@ -0,0 +1,31 @@
package jsonprovider
import (
"encoding/json"
"github.com/hashicorp/terraform/configs/configschema"
)
type attribute struct {
AttributeType json.RawMessage `json:"type,omitempty"`
Description string `json:"description,omitempty"`
Required bool `json:"required,omitempty"`
Optional bool `json:"optional,omitempty"`
Computed bool `json:"computed,omitempty"`
Sensitive bool `json:"sensitive,omitempty"`
}
func marshalAttribute(attr *configschema.Attribute) *attribute {
// we're not concerned about errors because at this point the schema has
// already been checked and re-checked.
attrTy, _ := attr.Type.MarshalJSON()
return &attribute{
AttributeType: attrTy,
Description: attr.Description,
Required: attr.Required,
Optional: attr.Optional,
Computed: attr.Computed,
Sensitive: attr.Sensitive,
}
}

View File

@ -0,0 +1,42 @@
package jsonprovider
import (
"encoding/json"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/configs/configschema"
)
func TestMarshalAttribute(t *testing.T) {
tests := []struct {
Input *configschema.Attribute
Want *attribute
}{
{
&configschema.Attribute{Type: cty.String, Optional: true, Computed: true},
&attribute{
AttributeType: json.RawMessage(`"string"`),
Optional: true,
Computed: true,
},
},
{ // collection types look a little odd.
&configschema.Attribute{Type: cty.Map(cty.String), Optional: true, Computed: true},
&attribute{
AttributeType: json.RawMessage(`["map","string"]`),
Optional: true,
Computed: true,
},
},
}
for _, test := range tests {
got := marshalAttribute(test.Input)
if !cmp.Equal(got, test.Want) {
t.Fatalf("wrong result:\n %v\n", cmp.Diff(got, test.Want))
}
}
}

View File

@ -0,0 +1,67 @@
package jsonprovider
import (
"github.com/hashicorp/terraform/configs/configschema"
)
type block struct {
Attributes map[string]*attribute `json:"attributes,omitempty"`
BlockTypes map[string]*blockType `json:"block_types,omitempty"`
}
type blockType struct {
NestingMode string `json:"nesting_mode,omitempty"`
Block *block `json:"block,omitempty"`
MinItems uint64 `json:"min_items,omitempty"`
MaxItems uint64 `json:"max_items,omitempty"`
}
func marshalBlockTypes(nestedBlock *configschema.NestedBlock) *blockType {
if nestedBlock == nil {
return &blockType{}
}
ret := &blockType{
Block: marshalBlock(&nestedBlock.Block),
MinItems: uint64(nestedBlock.MinItems),
MaxItems: uint64(nestedBlock.MaxItems),
}
switch nestedBlock.Nesting {
case configschema.NestingSingle:
ret.NestingMode = "single"
case configschema.NestingList:
ret.NestingMode = "list"
case configschema.NestingSet:
ret.NestingMode = "set"
case configschema.NestingMap:
ret.NestingMode = "map"
default:
ret.NestingMode = "invalid"
}
return ret
}
func marshalBlock(configBlock *configschema.Block) *block {
if configBlock == nil {
return &block{}
}
var ret block
if len(configBlock.Attributes) > 0 {
attrs := make(map[string]*attribute, len(configBlock.Attributes))
for k, attr := range configBlock.Attributes {
attrs[k] = marshalAttribute(attr)
}
ret.Attributes = attrs
}
if len(configBlock.BlockTypes) > 0 {
blockTypes := make(map[string]*blockType, len(configBlock.BlockTypes))
for k, bt := range configBlock.BlockTypes {
blockTypes[k] = marshalBlockTypes(bt)
}
ret.BlockTypes = blockTypes
}
return &ret
}

View File

@ -0,0 +1,66 @@
package jsonprovider
import (
"encoding/json"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/configs/configschema"
)
func TestMarshalBlock(t *testing.T) {
tests := []struct {
Input *configschema.Block
Want *block
}{
{
nil,
&block{},
},
{
Input: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Optional: true, Computed: true},
"ami": {Type: cty.String, Optional: true},
},
BlockTypes: map[string]*configschema.NestedBlock{
"network_interface": {
Nesting: configschema.NestingList,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"device_index": {Type: cty.String, Optional: true},
"description": {Type: cty.String, Optional: true},
},
},
},
},
},
Want: &block{
Attributes: map[string]*attribute{
"ami": {AttributeType: json.RawMessage(`"string"`), Optional: true},
"id": {AttributeType: json.RawMessage(`"string"`), Optional: true, Computed: true},
},
BlockTypes: map[string]*blockType{
"network_interface": {
NestingMode: "list",
Block: &block{
Attributes: map[string]*attribute{
"description": {AttributeType: json.RawMessage(`"string"`), Optional: true},
"device_index": {AttributeType: json.RawMessage(`"string"`), Optional: true},
},
},
},
},
},
},
}
for _, test := range tests {
got := marshalBlock(test.Input)
if !cmp.Equal(got, test.Want) {
t.Fatalf("wrong result:\n %v\n", cmp.Diff(got, test.Want))
}
}
}

View File

@ -0,0 +1,3 @@
// Package jsonprovider contains types and functions to marshal terraform
// provider schemas into a json formatted output.
package jsonprovider

View File

@ -0,0 +1,75 @@
package jsonprovider
import (
"encoding/json"
"github.com/hashicorp/terraform/terraform"
)
// FormatVersion represents the version of the json format and will be
// incremented for any change to this format that requires changes to a
// consuming parser.
const FormatVersion = "0.1"
// providers is the top-level object returned when exporting provider schemas
type providers struct {
FormatVersion string `json:"format_version"`
Schemas map[string]Provider `json:"provider_schemas"`
}
type Provider struct {
Provider *schema `json:"provider,omitempty"`
ResourceSchemas map[string]*schema `json:"resource_schemas,omitempty"`
DataSourceSchemas map[string]*schema `json:"data_source_schemas,omitempty"`
}
func newProviders() *providers {
schemas := make(map[string]Provider)
return &providers{
FormatVersion: FormatVersion,
Schemas: schemas,
}
}
func Marshal(s *terraform.Schemas) ([]byte, error) {
if len(s.Providers) == 0 {
return nil, nil
}
providers := newProviders()
for k, v := range s.Providers {
providers.Schemas[k] = marshalProvider(v)
}
// add some polish for the human consumers
ret, err := json.MarshalIndent(providers, "", " ")
return ret, err
}
func marshalProvider(tps *terraform.ProviderSchema) Provider {
if tps == nil {
return Provider{}
}
var ps *schema
var rs, ds map[string]*schema
if tps.Provider != nil {
ps = marshalSchema(tps.Provider)
}
if tps.ResourceTypes != nil {
rs = marshalSchemas(tps.ResourceTypes, tps.ResourceTypeSchemaVersions)
}
if tps.DataSources != nil {
ds = marshalSchemas(tps.DataSources, tps.ResourceTypeSchemaVersions)
}
return Provider{
Provider: ps,
ResourceSchemas: rs,
DataSourceSchemas: ds,
}
}

View File

@ -0,0 +1,177 @@
package jsonprovider
import (
"encoding/json"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/configs/configschema"
"github.com/hashicorp/terraform/terraform"
)
func TestMarshalProvider(t *testing.T) {
tests := []struct {
Input *terraform.ProviderSchema
Want Provider
}{
{
nil,
Provider{},
},
{
testProvider(),
Provider{
Provider: &schema{
Block: &block{
Attributes: map[string]*attribute{
"region": {
AttributeType: json.RawMessage(`"string"`),
Required: true,
},
},
},
},
ResourceSchemas: map[string]*schema{
"test_instance": {
Version: 42,
Block: &block{
Attributes: map[string]*attribute{
"id": {
AttributeType: json.RawMessage(`"string"`),
Optional: true,
Computed: true,
},
"ami": {
AttributeType: json.RawMessage(`"string"`),
Optional: true,
},
},
BlockTypes: map[string]*blockType{
"network_interface": {
Block: &block{
Attributes: map[string]*attribute{
"device_index": {
AttributeType: json.RawMessage(`"string"`),
Optional: true,
},
"description": {
AttributeType: json.RawMessage(`"string"`),
Optional: true,
},
},
},
NestingMode: "list",
},
},
},
},
},
DataSourceSchemas: map[string]*schema{
"test_data_source": {
Version: 3,
Block: &block{
Attributes: map[string]*attribute{
"id": {
AttributeType: json.RawMessage(`"string"`),
Optional: true,
Computed: true,
},
"ami": {
AttributeType: json.RawMessage(`"string"`),
Optional: true,
},
},
BlockTypes: map[string]*blockType{
"network_interface": {
Block: &block{
Attributes: map[string]*attribute{
"device_index": {
AttributeType: json.RawMessage(`"string"`),
Optional: true,
},
"description": {
AttributeType: json.RawMessage(`"string"`),
Optional: true,
},
},
},
NestingMode: "list",
},
},
},
},
},
},
},
}
for _, test := range tests {
got := marshalProvider(test.Input)
if !cmp.Equal(got, test.Want) {
t.Fatalf("wrong result:\n %v\n", cmp.Diff(got, test.Want))
}
}
}
func testProviders() *terraform.Schemas {
return &terraform.Schemas{
Providers: map[string]*terraform.ProviderSchema{
"test": testProvider(),
},
}
}
func testProvider() *terraform.ProviderSchema {
return &terraform.ProviderSchema{
Provider: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"region": {Type: cty.String, Required: true},
},
},
ResourceTypes: map[string]*configschema.Block{
"test_instance": {
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Optional: true, Computed: true},
"ami": {Type: cty.String, Optional: true},
},
BlockTypes: map[string]*configschema.NestedBlock{
"network_interface": {
Nesting: configschema.NestingList,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"device_index": {Type: cty.String, Optional: true},
"description": {Type: cty.String, Optional: true},
},
},
},
},
},
},
DataSources: map[string]*configschema.Block{
"test_data_source": {
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Optional: true, Computed: true},
"ami": {Type: cty.String, Optional: true},
},
BlockTypes: map[string]*configschema.NestedBlock{
"network_interface": {
Nesting: configschema.NestingList,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"device_index": {Type: cty.String, Optional: true},
"description": {Type: cty.String, Optional: true},
},
},
},
},
},
},
ResourceTypeSchemaVersions: map[string]uint64{
"test_instance": 42,
"test_data_source": 3,
},
}
}

View File

@ -0,0 +1,38 @@
package jsonprovider
import (
"github.com/hashicorp/terraform/configs/configschema"
)
type schema struct {
Version uint64 `json:"version"`
Block *block `json:"block,omitempty"`
}
// marshalSchema is a convenience wrapper around mashalBlock. Schema version
// should be set by the caller.
func marshalSchema(block *configschema.Block) *schema {
if block == nil {
return &schema{}
}
var ret schema
ret.Block = marshalBlock(block)
return &ret
}
func marshalSchemas(blocks map[string]*configschema.Block, rVersions map[string]uint64) map[string]*schema {
if blocks == nil {
return map[string]*schema{}
}
ret := make(map[string]*schema, len(blocks))
for k, v := range blocks {
ret[k] = marshalSchema(v)
version, ok := rVersions[k]
if ok {
ret[k].Version = version
}
}
return ret
}

View File

@ -0,0 +1,49 @@
package jsonprovider
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform/configs/configschema"
)
func TestMarshalSchemas(t *testing.T) {
tests := []struct {
Input map[string]*configschema.Block
Versions map[string]uint64
Want map[string]*schema
}{
{
nil,
map[string]uint64{},
map[string]*schema{},
},
}
for _, test := range tests {
got := marshalSchemas(test.Input, test.Versions)
if !cmp.Equal(got, test.Want) {
t.Fatalf("wrong result:\n %v\n", cmp.Diff(got, test.Want))
}
}
}
func TestMarshalSchema(t *testing.T) {
tests := map[string]struct {
Input *configschema.Block
Want *schema
}{
"nil_block": {
nil,
&schema{},
},
}
for _, test := range tests {
got := marshalSchema(test.Input)
if !cmp.Equal(got, test.Want) {
t.Fatalf("wrong result:\n %v\n", cmp.Diff(got, test.Want))
}
}
}

107
command/providers_schema.go Normal file
View File

@ -0,0 +1,107 @@
package command
import (
"fmt"
"os"
"github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/command/jsonprovider"
"github.com/hashicorp/terraform/tfdiags"
)
// ProvidersCommand is a Command implementation that prints out information
// about the providers used in the current configuration/state.
type ProvidersSchemaCommand struct {
Meta
}
func (c *ProvidersSchemaCommand) Help() string {
return providersSchemaCommandHelp
}
func (c *ProvidersSchemaCommand) Synopsis() string {
return "Prints the schemas of the providers used in the configuration"
}
func (c *ProvidersSchemaCommand) Run(args []string) int {
args, err := c.Meta.process(args, false)
if err != nil {
return 1
}
cmdFlags := c.Meta.defaultFlagSet("providers schema")
var jsonOutput bool
cmdFlags.BoolVar(&jsonOutput, "json", false, "produce JSON output")
cmdFlags.Usage = func() { c.Ui.Error(c.Help()) }
if err := cmdFlags.Parse(args); err != nil {
return 1
}
if !jsonOutput {
c.Ui.Error(
"The `terraform providers schema` command requires the `-json` flag.\n")
cmdFlags.Usage()
return 1
}
var diags tfdiags.Diagnostics
// Load the backend
b, backendDiags := c.Backend(nil)
diags = diags.Append(backendDiags)
if backendDiags.HasErrors() {
c.showDiagnostics(diags)
return 1
}
// We require a local backend
local, ok := b.(backend.Local)
if !ok {
c.showDiagnostics(diags) // in case of any warnings in here
c.Ui.Error(ErrUnsupportedLocalOp)
return 1
}
// we expect that the config dir is the cwd
cwd, err := os.Getwd()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error getting cwd: %s", err))
return 1
}
// Build the operation
opReq := c.Operation(b)
opReq.ConfigDir = cwd
opReq.ConfigLoader, err = c.initConfigLoader()
if err != nil {
diags = diags.Append(err)
c.showDiagnostics(diags)
return 1
}
// Get the context
ctx, _, ctxDiags := local.Context(opReq)
diags = diags.Append(ctxDiags)
if ctxDiags.HasErrors() {
c.showDiagnostics(diags)
return 1
}
schemas := ctx.Schemas()
jsonSchemas, err := jsonprovider.Marshal(schemas)
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to marshal provider schemas to json: %s", err))
return 1
}
c.Ui.Output(string(jsonSchemas))
return 0
}
const providersSchemaCommandHelp = `
Usage: terraform providers schemas -json
Prints out a json representation of the schemas for all providers used
in the current configuration.
`

View File

@ -0,0 +1,101 @@
package command
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/mitchellh/cli"
"github.com/hashicorp/terraform/helper/copy"
)
func TestProvidersSchema_error(t *testing.T) {
ui := new(cli.MockUi)
c := &ProvidersSchemaCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
Ui: ui,
},
}
if code := c.Run(nil); code != 1 {
fmt.Println(ui.OutputWriter.String())
t.Fatalf("expected error: \n%s", ui.OutputWriter.String())
}
}
func TestProvidersSchema_output(t *testing.T) {
// there's only one test at this time. This can be refactored to have
// multiple test cases in individual directories as needed.
inputDir := "test-fixtures/providers-schema"
td := tempDir(t)
copy.CopyDir(inputDir, td)
defer os.RemoveAll(td)
defer testChdir(t, td)()
p := showFixtureProvider()
ui := new(cli.MockUi)
m := Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
}
// `terrafrom init`
ic := &InitCommand{
Meta: m,
providerInstaller: &mockProviderInstaller{
Providers: map[string][]string{
"test": []string{"1.2.3"},
},
Dir: m.pluginDir(),
},
}
if code := ic.Run([]string{}); code != 0 {
t.Fatalf("init failed\n%s", ui.ErrorWriter)
}
// flush the init output from the mock ui
ui.OutputWriter.Reset()
// `terraform provider schemas` command
pc := &ProvidersSchemaCommand{Meta: m}
if code := pc.Run([]string{"-json"}); code != 0 {
t.Fatalf("wrong exit status %d; want 0\nstderr: %s", code, ui.ErrorWriter.String())
}
var got, want providerSchemas
gotString := ui.OutputWriter.String()
json.Unmarshal([]byte(gotString), &got)
wantFile, err := os.Open("output.json")
if err != nil {
t.Fatalf("err: %s", err)
}
defer wantFile.Close()
byteValue, err := ioutil.ReadAll(wantFile)
if err != nil {
t.Fatalf("err: %s", err)
}
json.Unmarshal([]byte(byteValue), &want)
if !cmp.Equal(got, want) {
fmt.Println(gotString)
t.Fatalf("wrong result:\n %v\n", cmp.Diff(got, want))
}
}
type providerSchemas struct {
FormatVersion string `json:"format_version"`
Schemas map[string]providerSchema `json:"provider_schemas"`
}
type providerSchema struct {
Provider interface{} `json:"provider,omitempty"`
ResourceSchemas map[string]interface{} `json:"resource_schemas,omitempty"`
DataSourceSchemas map[string]interface{} `json:"data_source_schemas,omitempty"`
}

View File

@ -0,0 +1,25 @@
{
"format_version": "0.1",
"provider_schemas": {
"test": {
"resource_schemas": {
"test_instance": {
"version": 0,
"block": {
"attributes": {
"ami": {
"type": "string",
"optional": true
},
"id": {
"type": "string",
"optional": true,
"computed": true
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,3 @@
provider "test" {
}

View File

@ -186,6 +186,12 @@ func initCommands(config *Config, services *disco.Disco) {
}, nil
},
"providers schema": func() (cli.Command, error) {
return &command.ProvidersSchemaCommand{
Meta: meta,
}, nil
},
"push": func() (cli.Command, error) {
return &command.PushCommand{
Meta: meta,