Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,22 @@ sweep, doctor, facts, rehearsal, curate) is retired and may not reappear.
word begins with a provider name, so the prefix removes the class rather
than escaping one case of it, and it makes a generated package
unmistakable at its import site.
- **list resource** — the list capability of a managed resource: the same
terraform type, streaming the identities of the objects that exist right
now. Terraform matches the two by type name and refuses to load a
provider whose list resource names no resource, so one is derived
exactly where an entity is both a resource and enumerable, and a
resource the bindings or emission refuse takes its list resource with
it. The earlier meaning — a list-only entity, enumerable but not
addressable — is retired: no resource can ever match such an entity, so
it could not be a list resource at all. Those entities are datasources.
- **resource identity schema** — the separate object terraform stores
beside a resource's state to name the remote object it stands for
(`resource.ResourceWithIdentity`). It is the addressing attributes plus
the `id`, all `RequiredForImport`: the framework requires an identity to
name at most one remote object per provider, and an `id` alone does not
where a parent scopes it. A list resource's results are identities in
this shape, which is why the resource must declare it.
- **addressing attribute** — a generated attribute that exists to fill an
operation's path parameter rather than to carry a field of the object.
Every path parameter above the item key becomes one: required, spelled
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func TestUnit_ProviderGenerateThenVerify_RoundTripsClean(t *testing.T) {
if code := Run([]string{"provider", "generate", "--postcheck=false"}, &stdout, &stderr); code != ExitOK {
t.Fatalf("generate exit = %d, stderr:\n%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "3 resources, 4 datasources, 1 list resources, 1 actions") {
if !strings.Contains(stdout.String(), "3 resources, 5 datasources, 3 list resources, 1 actions") {
t.Errorf("generate output does not report the fixture's entity counts:\n%s", stdout.String())
}
if !strings.Contains(stdout.String(), "postcheck skipped: postcheck disabled") {
Expand Down
10 changes: 7 additions & 3 deletions internal/emit/render_listresource.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,9 +266,13 @@ const resultLineDepth = 3
func readStringLocal(local string, n node) string {
indent := strings.Repeat("\t", resultLineDepth)
render := func(value string) string { return value }
if n.attr.Kind != ir.TypeString {
// A non-string identity is rendered through fmt: the identity is a
// string whatever the API keys its objects with.
// Decided from what the SDK hands back, not from the attribute's kind:
// an identity declared as a string arrives as uuid.UUID or time.Time
// often enough, and only the SDK type says whether an assignment
// compiles. A value that is not already a string goes through fmt,
// because the identity is a string whatever the API keys its objects
// with.
if strings.TrimPrefix(n.fb.Access.SDKType, "*") != "string" {
render = func(value string) string { return "fmt.Sprintf(\"%v\", " + value + ")" }
}
if strings.HasPrefix(n.fb.Access.SDKType, "*") {
Expand Down
46 changes: 40 additions & 6 deletions internal/emit/render_listresource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func scopedListResource(t *testing.T) *ServiceFiles {
m.ListResources[0].AddressingSchema = &ir.AttributeTree{Attributes: []ir.Attribute{
{Name: "tenant_id", WireName: "tenantId", Kind: ir.TypeString, ComputedOptionalRequired: ir.Required},
}}
b.ListResources["audit_event"].List.Params = []sdkbind.CallParam{
b.ListResources["http_server"].List.Params = []sdkbind.CallParam{
{Local: "tenantId", GoType: "string", Wire: "tenantId"}}

out, err := RenderServices(fictionalProviderCore(), m, b)
Expand All @@ -29,12 +29,46 @@ func scopedListResource(t *testing.T) *ServiceFiles {
return out
}

// TestUnit_ListResource_GoesWithTheResourceItLists proves a list resource is
// withheld when its resource is not served. Terraform refuses to load a
// provider whose list resource names no resource, and refuses the whole
// provider rather than that one entity — so emitting it would cost every
// other entity too.
func TestUnit_ListResource_GoesWithTheResourceItLists(t *testing.T) {
m, b := fictionalModel(), fictionalBindings()
delete(b.Resources, "http_server")

out, err := RenderServices(fictionalProviderCore(), m, b)
if err != nil {
t.Fatalf("an unserved resource must not fail the run: %v", err)
}
for _, f := range out.Files {
if strings.Contains(f.Path, "list-resources/servers/v7/http_server") {
t.Fatalf("a list resource was emitted for a resource that is not served: %s", f.Path)
}
}
if len(out.Registrations.ListResources.Registrations) != 0 {
t.Fatalf("a list resource was registered with no resource to match: %+v",
out.Registrations.ListResources)
}

var said bool
for _, e := range out.Excluded {
if e.Key == "http_server" && strings.Contains(e.Reason, "names no resource") {
said = true
}
}
if !said {
t.Fatalf("the report does not say why the list resource went: %+v", out.Excluded)
}
}

// TestUnit_ListResource_ReadsItsPathParametersFromTheListBlock proves a
// collection path's parameters are declared as the list block's own
// configuration and read from there, rather than refusing the entity.
func TestUnit_ListResource_ReadsItsPathParametersFromTheListBlock(t *testing.T) {
out := scopedListResource(t)
dir := "internal/services/list-resources/audit/v7/audit_event/"
dir := "internal/services/list-resources/servers/v7/http_server/"

schema := string(fileByPath(t, out, dir+"list_resource.go").Content)
for _, want := range []string{
Expand Down Expand Up @@ -72,7 +106,7 @@ func TestUnit_ListResource_ReadsItsPathParametersFromTheListBlock(t *testing.T)
func TestUnit_ListResource_MocksAParameterisedPathByShape(t *testing.T) {
out := scopedListResource(t)
test := string(fileByPath(t, out,
"internal/services/list-resources/audit/v7/audit_event/list_resource_test.go").Content)
"internal/services/list-resources/servers/v7/http_server/list_resource_test.go").Content)

for _, want := range []string{
"httpmock.RegisterResponder(\"GET\", `=~^",
Expand All @@ -95,7 +129,7 @@ func TestUnit_ListResource_MocksAParameterisedPathByShape(t *testing.T) {
func TestUnit_ListResource_ExampleSuppliesTheRequiredAddressing(t *testing.T) {
out := scopedListResource(t)
example := string(fileByPath(t, out,
"examples/list-resources/petstore_audit_event/list-resource.tfquery.hcl").Content)
"examples/list-resources/petstore_http_server/list-resource.tfquery.hcl").Content)

if !strings.Contains(example, "tenant_id = ") {
t.Errorf("the example does not supply the required addressing:\n%s", example)
Expand All @@ -107,7 +141,7 @@ func TestUnit_ListResource_ExampleSuppliesTheRequiredAddressing(t *testing.T) {
// block, no config model, and the mock matched by exact URL.
func TestUnit_ListResource_WithoutAddressingDeclaresNoConfiguration(t *testing.T) {
out := renderFictional(t)
dir := "internal/services/list-resources/audit/v7/audit_event/"
dir := "internal/services/list-resources/servers/v7/http_server/"

if schema := string(fileByPath(t, out, dir+"list_resource.go").Content); strings.Contains(schema, "Attributes: map[string]listschema.Attribute{") {
t.Errorf("an unparameterised collection path must declare an empty list block:\n%s", schema)
Expand All @@ -118,7 +152,7 @@ func TestUnit_ListResource_WithoutAddressingDeclaresNoConfiguration(t *testing.T
if list := string(fileByPath(t, out, dir+"list.go").Content); strings.Contains(list, "req.Config.Get") {
t.Errorf("an unparameterised collection path reads no configuration:\n%s", list)
}
if test := string(fileByPath(t, out, dir+"list_resource_test.go").Content); !strings.Contains(test, `httpmock.RegisterResponder("GET", "https://unit.invalid/v7/audit-events"`) {
if test := string(fileByPath(t, out, dir+"list_resource_test.go").Content); !strings.Contains(test, `httpmock.RegisterResponder("GET", "https://unit.invalid/v7/http-servers"`) {
t.Errorf("an unparameterised collection path is mocked by exact URL:\n%s", test)
}
}
18 changes: 18 additions & 0 deletions internal/emit/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ func RenderServices(pc ProviderCore, m *ir.Model, b *sdkbind.Bindings) (*Service
e := &serviceRenderer{pc: pc, bindings: b}
out := &ServiceFiles{}

// The resources that reached the provider. A list resource is matched to
// one by type name and cannot be registered without it, so this decides
// which list resources may follow.
served := map[string]bool{}

for i := range m.Resources {
r := &m.Resources[i]
rb := b.Resources[r.Names.Key]
Expand All @@ -101,6 +106,7 @@ func RenderServices(pc ProviderCore, m *ir.Model, b *sdkbind.Bindings) (*Service
}
out.Files = append(out.Files, files...)
out.Registrations.Resources.add(e.registration(kindResources, r.Names, "New"+r.Names.Pascal+"Resource"))
served[r.Names.Key] = true
}

for i := range m.Datasources {
Expand All @@ -127,6 +133,18 @@ func RenderServices(pc ProviderCore, m *ir.Model, b *sdkbind.Bindings) (*Service
if lb == nil {
continue
}
// Terraform refuses to load a provider that offers a list resource
// with no managed resource of the same type name, and refuses the
// whole provider rather than that one entity. A resource the
// bindings or emission already refused therefore takes its list
// resource with it.
if !served[lr.Names.Key] {
out.Excluded = append(out.Excluded, ir.Exclusion{
Key: lr.Names.Key,
Reason: "list: the resource it lists is not served, and terraform refuses a provider whose list resource names no resource",
})
continue
}
files, err := e.listResource(lr, lb)
if err != nil {
if reason, refused := excludes(err); refused {
Expand Down
26 changes: 17 additions & 9 deletions internal/emit/services_errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func TestUnit_RenderServices_SkipsEntitiesTheBindingsLack(t *testing.T) {
b := fictionalBindings()
delete(b.Resources, "alert_rule")
delete(b.Datasources, "license")
delete(b.ListResources, "audit_event")
delete(b.ListResources, "http_server")
delete(b.Actions, "http_server_restart")

out, err := RenderServices(fictionalProviderCore(), fictionalModel(), b)
Expand Down Expand Up @@ -223,15 +223,15 @@ func TestUnit_RenderServices_NamesTheEntityAndAttributeAtFault(t *testing.T) {
// A list resource whose element carries no id.
m, b = fictionalModel(), fictionalBindings()
m.ListResources[0].Schema.Attributes = m.ListResources[0].Schema.Attributes[1:]
b.ListResources["audit_event"].Fields = b.ListResources["audit_event"].Fields[1:]
expectRenderExclusion(t, pc, m, b, "audit_event", "id")
b.ListResources["http_server"].Fields = b.ListResources["http_server"].Fields[1:]
expectRenderExclusion(t, pc, m, b, "http_server", "id")

// A list resource whose call demands a path parameter no addressing
// attribute answers.
m, b = fictionalModel(), fictionalBindings()
b.ListResources["audit_event"].List.Params = []sdkbind.CallParam{
b.ListResources["http_server"].List.Params = []sdkbind.CallParam{
{Local: "parentId", GoType: "string", Wire: "parentId"}}
expectRenderExclusion(t, pc, m, b, "audit_event", "parentId")
expectRenderExclusion(t, pc, m, b, "http_server", "parentId")

// A lookup datasource without a read call.
m, b = fictionalModel(), fictionalBindings()
Expand Down Expand Up @@ -356,11 +356,19 @@ func TestUnit_RenderServices_ExcludesTheEntityWhoseShapeItCannotServe(t *testing
if err != nil {
t.Fatalf("one unservable entity must not fail the run: %v", err)
}
if len(out.Excluded) != 1 {
t.Fatalf("want exactly one exclusion, got %d: %+v", len(out.Excluded), out.Excluded)
// Two: the resource, and the list resource that can no longer name it.
// Terraform refuses a provider whose list resource matches no resource,
// so the pair goes together or the whole provider fails to load.
if len(out.Excluded) != 2 {
t.Fatalf("want the resource and its list resource excluded, got %d: %+v", len(out.Excluded), out.Excluded)
}
if out.Excluded[0].Key != "http_server" {
t.Fatalf("the exclusion must name the entity, got %q", out.Excluded[0].Key)
for _, e := range out.Excluded {
if e.Key != "http_server" {
t.Fatalf("the exclusion must name the entity, got %q", e.Key)
}
}
if !strings.Contains(out.Excluded[1].Reason, "names no resource") {
t.Fatalf("the list resource must say why it went, got %q", out.Excluded[1].Reason)
}
if !strings.Contains(out.Excluded[0].Reason, "delete") {
t.Fatalf("the reason must say what was missing, got %q", out.Excluded[0].Reason)
Expand Down
8 changes: 4 additions & 4 deletions internal/emit/services_fixture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,8 @@ func fictionalModel() *ir.Model {
},
ListResources: []ir.ListResource{
{
Names: names("audit_event", "AuditEvent", "audit"),
ListOperation: ir.Operation{Kind: ir.OperationList, Method: "GET", PathTemplate: "/v7/audit-events", SuccessCode: 200},
Names: names("http_server", "HTTPServer", "servers"),
ListOperation: ir.Operation{Kind: ir.OperationList, Method: "GET", PathTemplate: "/v7/http-servers", SuccessCode: 200},
Schema: &ir.AttributeTree{Attributes: []ir.Attribute{
{Name: "id", WireName: "id", Kind: ir.TypeString, ComputedOptionalRequired: ir.Computed},
{Name: "name", WireName: "name", Kind: ir.TypeString, ComputedOptionalRequired: ir.Computed},
Expand Down Expand Up @@ -333,8 +333,8 @@ func fictionalBindings() *sdkbind.Bindings {
},
},
ListResources: map[string]*sdkbind.ListResourceBinding{
"audit_event": {
Key: "audit_event",
"http_server": {
Key: "http_server",
List: call("client.AuditEvents().Get(ctx, nil)", nil, "", "models.AuditEventCollectionResponseable", "error"),
ElementType: "models.AuditEventable",
CollectionAccess: "GetValue()",
Expand Down
14 changes: 7 additions & 7 deletions internal/emit/services_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,11 @@ func TestUnit_RenderServices_TheFileGrammarIsComplete(t *testing.T) {
"internal/services/datasources/licenses/v7/license/datasource.go",
"internal/services/datasources/licenses/v7/license/read.go",
// list resource.
"internal/services/list-resources/audit/v7/audit_event/list_resource.go",
"internal/services/list-resources/audit/v7/audit_event/list.go",
"internal/services/list-resources/audit/v7/audit_event/model.go",
"internal/services/list-resources/audit/v7/audit_event/list_resource_test.go",
"internal/services/list-resources/audit/v7/audit_event/tests/responses/list.json",
"internal/services/list-resources/servers/v7/http_server/list_resource.go",
"internal/services/list-resources/servers/v7/http_server/list.go",
"internal/services/list-resources/servers/v7/http_server/model.go",
"internal/services/list-resources/servers/v7/http_server/list_resource_test.go",
"internal/services/list-resources/servers/v7/http_server/tests/responses/list.json",
// action.
"internal/services/actions/servers/v7/http_server_restart/action.go",
"internal/services/actions/servers/v7/http_server_restart/invoke.go",
Expand All @@ -86,7 +86,7 @@ func TestUnit_RenderServices_TheFileGrammarIsComplete(t *testing.T) {
"examples/resources/petstore_http_server/import.sh",
"examples/data-sources/petstore_http_server/data-source.tf",
"examples/data-sources/petstore_license/data-source.tf",
"examples/list-resources/petstore_audit_event/list-resource.tfquery.hcl",
"examples/list-resources/petstore_http_server/list-resource.tfquery.hcl",
"examples/actions/petstore_http_server_restart/action.tf",
}
for _, p := range expected {
Expand Down Expand Up @@ -306,7 +306,7 @@ func TestUnit_RenderServices_ListEnvelopeIsDataDriven(t *testing.T) {
if !strings.Contains(dsJSON, `"http_servers": [`) {
t.Fatalf("datasource list fixture ignores the envelope key:\n%s", dsJSON)
}
listJSON := string(fileByPath(t, out, "internal/services/list-resources/audit/v7/audit_event/tests/responses/list.json").Content)
listJSON := string(fileByPath(t, out, "internal/services/list-resources/servers/v7/http_server/tests/responses/list.json").Content)
if !strings.Contains(listJSON, `"records": [`) {
t.Fatalf("list-resource fixture ignores the envelope key:\n%s", listJSON)
}
Expand Down
35 changes: 35 additions & 0 deletions internal/intermediate_representation/attribute_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,41 @@ func refuse(attribute *Attribute, reason string) {
attribute.UnsupportedReason = reason
}

// reservedRootNames are the names terraform reserves at the root of a
// resource or datasource schema, because a practitioner writing one means the
// meta-argument rather than the attribute. The set is
// fwschema.ReservedResourceAttributeNames.
var reservedRootNames = map[string]bool{
"connection": true, "count": true, "depends_on": true, "for_each": true,
"lifecycle": true, "provider": true, "provisioner": true,
}

// refuseReservedRootNames refuses a root attribute terraform will not accept
// the name of.
//
// Refused rather than renamed: the name is what a practitioner writes, and
// choosing another belongs in a correction to the document rather than in a
// rule here. The cost of declaring one is the whole provider — terraform
// rejects the schema and loads none of it — so this is not a refusal that can
// be deferred to the operator's judgement.
//
// Root only, matching the framework: the same name nested inside an object is
// an ordinary field and needs no special syntax.
func refuseReservedRootNames(tree *AttributeTree) {
if tree == nil {
return
}
for index := range tree.Attributes {
attribute := &tree.Attributes[index]
if !reservedRootNames[attribute.Name] {
continue
}
refuse(attribute, fmt.Sprintf(
"terraform reserves %q at the root of a schema, and refuses to load a provider that declares it; rename the property in a correction",
attribute.Name))
}
}

// mergeExtensions folds the read side'schema property extensions under the
// create side'schema, the create side winning a collision: the writable view is
// where behaviour annotations are authored.
Expand Down
10 changes: 5 additions & 5 deletions internal/intermediate_representation/attributes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func TestAttributes_TypeMapping(t *testing.T) {
kind AttributeType
}{
{"name", TypeString},
{"count", TypeInt64},
{"quantity", TypeInt64},
{"ratio", TypeFloat64},
{"enabled", TypeBool},
{"labels", TypeList},
Expand Down Expand Up @@ -310,15 +310,15 @@ components:

func TestAttributes_ConditionalValidity(t *testing.T) {
tree := thingTree(t)
want := []ConditionalValidity{{Property: "mode", Equals: "standard", Valid: []string{"count"}}}
want := []ConditionalValidity{{Property: "mode", Equals: "standard", Valid: []string{"quantity"}}}
if !reflect.DeepEqual(tree.ConditionalValidities, want) {
t.Errorf("conditional validities = %+v, want %+v", tree.ConditionalValidities, want)
}
}

func TestAttributes_Dependencies(t *testing.T) {
tree := thingTree(t)
want := []Dependency{{Attribute: "ratio", Requires: []string{"count"}}}
want := []Dependency{{Attribute: "ratio", Requires: []string{"quantity"}}}
if !reflect.DeepEqual(tree.Dependencies, want) {
t.Errorf("dependencies = %+v, want %+v", tree.Dependencies, want)
}
Expand All @@ -338,7 +338,7 @@ func TestAttributes_ValidConfigurations(t *testing.T) {
Discriminator: "mode",
Variants: []ConfigVariant{
{Value: "custom", Valid: []string{"proxy_host"}},
{Value: "standard", Valid: []string{"count"}},
{Value: "standard", Valid: []string{"quantity"}},
},
}}
if !reflect.DeepEqual(tree.ValidConfigurations, want) {
Expand Down Expand Up @@ -368,7 +368,7 @@ func TestAttributes_OrderFollowsTheDocument(t *testing.T) {
got = append(got, a.Name)
}
want := []string{
"name", "mode", "region", "filled", "tier", "proxy_host", "notes", "count",
"name", "mode", "region", "filled", "tier", "proxy_host", "notes", "quantity",
"ratio", "enabled", "labels", "rules", "settings", "extras",
"forced", "flaky", "stamp", "id", "etag",
}
Expand Down
Loading