diff --git a/command/jsonprovider/attribute.go b/command/jsonprovider/attribute.go new file mode 100644 index 000000000..dbdd4590e --- /dev/null +++ b/command/jsonprovider/attribute.go @@ -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, + } +} diff --git a/command/jsonprovider/attribute_test.go b/command/jsonprovider/attribute_test.go new file mode 100644 index 000000000..8e2d92f07 --- /dev/null +++ b/command/jsonprovider/attribute_test.go @@ -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)) + } + } +} diff --git a/command/jsonprovider/block.go b/command/jsonprovider/block.go new file mode 100644 index 000000000..c12fc90b6 --- /dev/null +++ b/command/jsonprovider/block.go @@ -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 +} diff --git a/command/jsonprovider/block_test.go b/command/jsonprovider/block_test.go new file mode 100644 index 000000000..59841499a --- /dev/null +++ b/command/jsonprovider/block_test.go @@ -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)) + } + } +} diff --git a/command/jsonprovider/doc.go b/command/jsonprovider/doc.go new file mode 100644 index 000000000..f7be0ade9 --- /dev/null +++ b/command/jsonprovider/doc.go @@ -0,0 +1,3 @@ +// Package jsonprovider contains types and functions to marshal terraform +// provider schemas into a json formatted output. +package jsonprovider diff --git a/command/jsonprovider/provider.go b/command/jsonprovider/provider.go new file mode 100644 index 000000000..cdadbf408 --- /dev/null +++ b/command/jsonprovider/provider.go @@ -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, + } +} diff --git a/command/jsonprovider/provider_test.go b/command/jsonprovider/provider_test.go new file mode 100644 index 000000000..57ca05bb3 --- /dev/null +++ b/command/jsonprovider/provider_test.go @@ -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, + }, + } +} diff --git a/command/jsonprovider/schema.go b/command/jsonprovider/schema.go new file mode 100644 index 000000000..dc9e30ce9 --- /dev/null +++ b/command/jsonprovider/schema.go @@ -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 +} diff --git a/command/jsonprovider/schema_test.go b/command/jsonprovider/schema_test.go new file mode 100644 index 000000000..a4939d7d2 --- /dev/null +++ b/command/jsonprovider/schema_test.go @@ -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)) + } + } +} diff --git a/command/providers_schema.go b/command/providers_schema.go new file mode 100644 index 000000000..b18e413c8 --- /dev/null +++ b/command/providers_schema.go @@ -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. +` diff --git a/command/providers_schema_test.go b/command/providers_schema_test.go new file mode 100644 index 000000000..78bbd2df8 --- /dev/null +++ b/command/providers_schema_test.go @@ -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"` +} diff --git a/command/test-fixtures/providers-schema/output.json b/command/test-fixtures/providers-schema/output.json new file mode 100644 index 000000000..53424e022 --- /dev/null +++ b/command/test-fixtures/providers-schema/output.json @@ -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 + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/command/test-fixtures/providers-schema/provider.tf b/command/test-fixtures/providers-schema/provider.tf new file mode 100644 index 000000000..06e511104 --- /dev/null +++ b/command/test-fixtures/providers-schema/provider.tf @@ -0,0 +1,3 @@ +provider "test" { + +} diff --git a/commands.go b/commands.go index ac4ca02b0..002409367 100644 --- a/commands.go +++ b/commands.go @@ -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,