From e454f37403dd18c0fdad7879543f0e8685fad641 Mon Sep 17 00:00:00 2001 From: ShocOne <62835948+ShocOne@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:15:22 +0100 Subject: [PATCH] fix: a list resource is the list capability of a resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terraform matches a list resource to a managed resource by type name and refuses to load a provider whose list resource names none — refusing the whole provider, not the one entity. Every list resource emitted was unmatched: classification produced one exactly where an entity had a collection GET and no item GET, which is the shape no resource can have. So the entity that is enumerable but not addressable is a datasource, and the list capability belongs to a resource the API can enumerate. A resource the bindings or emission refuse takes its list resource with it, because the pair is what terraform requires. Terraform also reserves seven names at the root of a schema, and rejects a provider declaring one. A root attribute so named is refused with the remedy named: a correction renaming the property. Nested, the same name is an ordinary field and stands. Neither defect is visible to postcheck, which builds the tree and never loads it. Co-Authored-By: Claude Opus 5 (1M context) --- docs/glossary.md | 16 ++++ internal/cli/provider_test.go | 2 +- internal/emit/render_listresource.go | 10 ++- internal/emit/render_listresource_test.go | 46 ++++++++++-- internal/emit/services.go | 18 +++++ internal/emit/services_errors_test.go | 26 ++++--- internal/emit/services_fixture_test.go | 8 +- internal/emit/services_test.go | 14 ++-- .../attribute_types.go | 35 +++++++++ .../attributes_test.go | 10 +-- .../constraints_test.go | 73 ++++++++++++++++++- .../intermediate_representation/derive.go | 18 ++++- .../derive_test.go | 49 +++++++++---- .../intermediate_representation_test.go | 6 +- internal/providergen/curated_test.go | 10 ++- internal/specmodel/classify.go | 35 ++++++--- internal/specmodel/classify_test.go | 25 +++++-- 17 files changed, 329 insertions(+), 72 deletions(-) diff --git a/docs/glossary.md b/docs/glossary.md index 9e30540..312e212 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -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 diff --git a/internal/cli/provider_test.go b/internal/cli/provider_test.go index b0028f2..c94121c 100644 --- a/internal/cli/provider_test.go +++ b/internal/cli/provider_test.go @@ -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") { diff --git a/internal/emit/render_listresource.go b/internal/emit/render_listresource.go index ba4ceaf..b9b2131 100644 --- a/internal/emit/render_listresource.go +++ b/internal/emit/render_listresource.go @@ -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, "*") { diff --git a/internal/emit/render_listresource_test.go b/internal/emit/render_listresource_test.go index 14d9e9a..6529d08 100644 --- a/internal/emit/render_listresource_test.go +++ b/internal/emit/render_listresource_test.go @@ -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) @@ -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{ @@ -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\", `=~^", @@ -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) @@ -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) @@ -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) } } diff --git a/internal/emit/services.go b/internal/emit/services.go index 1455320..617e202 100644 --- a/internal/emit/services.go +++ b/internal/emit/services.go @@ -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] @@ -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 { @@ -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 { diff --git a/internal/emit/services_errors_test.go b/internal/emit/services_errors_test.go index 2576307..1edd10d 100644 --- a/internal/emit/services_errors_test.go +++ b/internal/emit/services_errors_test.go @@ -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) @@ -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() @@ -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) diff --git a/internal/emit/services_fixture_test.go b/internal/emit/services_fixture_test.go index 9ed1771..491453c 100644 --- a/internal/emit/services_fixture_test.go +++ b/internal/emit/services_fixture_test.go @@ -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}, @@ -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()", diff --git a/internal/emit/services_test.go b/internal/emit/services_test.go index a9eb8f8..f27991c 100644 --- a/internal/emit/services_test.go +++ b/internal/emit/services_test.go @@ -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", @@ -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 { @@ -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) } diff --git a/internal/intermediate_representation/attribute_types.go b/internal/intermediate_representation/attribute_types.go index 4b52650..f853eb1 100644 --- a/internal/intermediate_representation/attribute_types.go +++ b/internal/intermediate_representation/attribute_types.go @@ -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. diff --git a/internal/intermediate_representation/attributes_test.go b/internal/intermediate_representation/attributes_test.go index b58af65..a966ddc 100644 --- a/internal/intermediate_representation/attributes_test.go +++ b/internal/intermediate_representation/attributes_test.go @@ -22,7 +22,7 @@ func TestAttributes_TypeMapping(t *testing.T) { kind AttributeType }{ {"name", TypeString}, - {"count", TypeInt64}, + {"quantity", TypeInt64}, {"ratio", TypeFloat64}, {"enabled", TypeBool}, {"labels", TypeList}, @@ -310,7 +310,7 @@ 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) } @@ -318,7 +318,7 @@ func TestAttributes_ConditionalValidity(t *testing.T) { 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) } @@ -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) { @@ -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", } diff --git a/internal/intermediate_representation/constraints_test.go b/internal/intermediate_representation/constraints_test.go index bdb1e7e..08eb92d 100644 --- a/internal/intermediate_representation/constraints_test.go +++ b/internal/intermediate_representation/constraints_test.go @@ -1,6 +1,9 @@ package intermediate_representation -import "testing" +import ( + "strings" + "testing" +) // constrainedSpec is one resource whose create body declares every // constraint keyword, so one derivation proves the whole set reaches the @@ -247,6 +250,74 @@ components: } } +// TestUnit_Attribute_RefusesAReservedRootName proves a root attribute +// terraform reserves is refused rather than emitted. Declaring one costs the +// whole provider: terraform rejects the schema and loads none of it, so the +// entity beside it goes too. +func TestUnit_Attribute_RefusesAReservedRootName(t *testing.T) { + const spec = `openapi: 3.0.3 +info: {title: T, version: "1"} +paths: + /groups: + post: + requestBody: + content: + application/json: + schema: {$ref: '#/components/schemas/Group'} + responses: + "201": + content: + application/json: + schema: {$ref: '#/components/schemas/Group'} + /groups/{groupId}: + get: + responses: + "200": + content: + application/json: + schema: {$ref: '#/components/schemas/Group'} + delete: + responses: + "204": {description: gone} +components: + schemas: + Group: + type: object + properties: + count: {type: integer} + lifecycle: {type: string} + name: {type: string} + nested: + type: object + properties: + count: {type: integer} +` + r := resourceByKey(t, mustDerive(t, spec, testConfig()), "group") + + for _, name := range []string{"count", "lifecycle"} { + got := attribute(t, r.Schema, name) + if !got.Unsupported { + t.Errorf("%q is reserved at the root and was emitted anyway", name) + } + if !strings.Contains(got.UnsupportedReason, "terraform reserves") { + t.Errorf("%q refused for %q, which does not say why", name, got.UnsupportedReason) + } + } + if attribute(t, r.Schema, "name").Unsupported { + t.Error("an ordinary root attribute was refused") + } + + // The same name nested inside an object is an ordinary field: terraform + // reserves it only where a practitioner would write a meta-argument. + nested := attribute(t, r.Schema, "nested") + if nested.Nested == nil { + t.Fatalf("nested = %+v", nested) + } + if attribute(t, nested.Nested, "count").Unsupported { + t.Error("a reserved name nested inside an object was refused") + } +} + func assertBound(t *testing.T, name string, got *int64, want int64) { t.Helper() if got == nil { diff --git a/internal/intermediate_representation/derive.go b/internal/intermediate_representation/derive.go index 7143a1c..aac2ffe 100644 --- a/internal/intermediate_representation/derive.go +++ b/internal/intermediate_representation/derive.go @@ -269,6 +269,7 @@ func (derivation *deriver) resource(classification specmodel.Classification, nam tree := buildTree(createBody, readBody, updateBody, classification.MissingUpdate) keyParam, keyType := itemKeyParam(classification.ItemPath, readFull) ensureID(tree, keyParam, keyType) + refuseReservedRootNames(tree) readOperation := derivation.operation(classification.Read, OperationRead) if readOperation != nil { ensureParentParameters(tree, parentParameters(readOperation.PathParameters)) @@ -393,12 +394,20 @@ func (derivation *deriver) datasource(classification specmodel.Classification, n if readFull != nil { readBody = readFull.SuccessSchema() } + // An entity the API only enumerates has no read to describe one object, + // so the collection's own element describes it instead. That is the same + // schema a list resource reads, and the only account of the shape the + // document offers. + if readBody == nil { + readBody = listElementSchema(derivation.full(classification.List)) + } itemTree := buildTree(nil, readBody, nil, false) keyParam, keyType := itemKeyParam(classification.ItemPath, readFull) ensureID(itemTree, keyParam, keyType) if classification.LookupByKey { requireKey(itemTree, keyParam, keyType) + refuseReservedRootNames(itemTree) readOperation := derivation.operation(classification.Read, OperationRead) if readOperation != nil { ensureParentParameters(itemTree, parentParameters(readOperation.PathParameters)) @@ -427,6 +436,7 @@ func (derivation *deriver) datasource(classification specmodel.Classification, n if listOperation != nil { ensureParentParameters(companionTree, listOperation.PathParameters) } + refuseReservedRootNames(companionTree) return Datasource{ Names: names, @@ -436,15 +446,20 @@ func (derivation *deriver) datasource(classification specmodel.Classification, n } } +// listResource derives the list capability of a managed resource: terraform +// matches it to that resource by type name, so it carries the entity's own +// Names and exists only where the resource does. func (derivation *deriver) listResource(classification specmodel.Classification, names Names) ListResource { listFull := derivation.full(classification.List) element := listElementSchema(listFull) listOperation := *derivation.operation(classification.List, OperationList) + addressing := addressingSchema(listOperation.PathParameters) + refuseReservedRootNames(addressing) return ListResource{ Names: names, ListOperation: listOperation, Schema: buildTree(nil, element, nil, false), - AddressingSchema: addressingSchema(listOperation.PathParameters), + AddressingSchema: addressing, ListEnvelopeKey: listEnvelopeKey(listFull), } } @@ -454,6 +469,7 @@ func (derivation *deriver) action(classification specmodel.Classification, names var request *AttributeTree if createFull != nil && createFull.RequestBody != nil { request = buildTree(createFull.RequestBody, nil, nil, false) + refuseReservedRootNames(request) } return Action{ Names: names, diff --git a/internal/intermediate_representation/derive_test.go b/internal/intermediate_representation/derive_test.go index 4f7654f..832f77b 100644 --- a/internal/intermediate_representation/derive_test.go +++ b/internal/intermediate_representation/derive_test.go @@ -35,15 +35,16 @@ func TestDerive_ModelShape(t *testing.T) { if got := len(m.Resources); got != 1 { t.Fatalf("%d resources, want 1", got) } - // thing's companion, the stream list+read entity, and the lookup-only - // setting entity all yield datasources. - if got := len(m.Datasources); got != 3 { - t.Fatalf("%d datasources, want 3", got) - } - // Only events is list-only; streams has a read so it becomes a - // datasource instead. + // thing's companion, the stream list+read entity, the lookup-only + // setting entity, and events — which the API enumerates and cannot + // address, so it is a datasource rather than a list resource. + if got := len(m.Datasources); got != 4 { + t.Fatalf("%d datasources, want 4", got) + } + // thing is the only entity that is both a resource and enumerable, so + // it is the only one whose list capability terraform can match. if got := len(m.ListResources); got != 1 { - t.Fatalf("%d list resources, want 1 (events)", got) + t.Fatalf("%d list resources, want 1 (thing)", got) } if got := len(m.Actions); got != 1 { t.Fatalf("%d actions, want 1", got) @@ -288,23 +289,43 @@ func TestDerive_ListReadEntityYieldsDatasource(t *testing.T) { } } +// TestDerive_ListResource proves the list capability belongs to a resource +// and shares its terraform type, which is how terraform matches the two. func TestDerive_ListResource(t *testing.T) { m := mustDerive(t, thingSpec, testConfig()) for _, lr := range m.ListResources { - if lr.Names.Key != "event" { + if lr.Names.Key != "thing" { continue } if lr.ListOperation.Kind != OperationList || lr.ListOperation.Method != "GET" || lr.ListOperation.SuccessCode != 200 { t.Errorf("list op = %+v", lr.ListOperation) } - for _, name := range []string{"at", "level"} { + resource := resourceByKey(t, m, "thing") + if lr.Names.TerraformType != resource.Names.TerraformType { + t.Errorf("list resource type = %q, resource type = %q; terraform matches them by name", + lr.Names.TerraformType, resource.Names.TerraformType) + } + for _, name := range []string{"name", "id"} { if a := attribute(t, lr.Schema, name); a.ComputedOptionalRequired != Computed { t.Errorf("%q = %+v", name, a) } } return } - t.Fatalf("no event list resource in %+v", m.ListResources) + t.Fatalf("no thing list resource in %+v", m.ListResources) +} + +// TestDerive_ListOnlyEntityIsADatasource proves a collection the API cannot +// address one member of yields a datasource: no resource can match it, and +// terraform refuses a provider whose list resource names no resource. +func TestDerive_ListOnlyEntityIsADatasource(t *testing.T) { + m := mustDerive(t, thingSpec, testConfig()) + for _, lr := range m.ListResources { + if lr.Names.Key == "event" { + t.Fatalf("event is enumerable but not addressable, and became a list resource") + } + } + datasourceByKey(t, m, "event") } // TestUnit_AddressingSchema_TakesEveryPathParameter proves a collection @@ -460,10 +481,10 @@ paths: m := mustDerive(t, spec, testConfig()) // Both versions generate: the later collection path takes a key // extended by its distinguishing segment, and nothing is excluded. - if got := len(m.ListResources); got != 2 { - t.Fatalf("%d list resources from two colliding versions, want 2", got) + if got := len(m.Datasources); got != 2 { + t.Fatalf("%d datasources from two colliding versions, want 2", got) } - first, second := m.ListResources[0], m.ListResources[1] + first, second := m.Datasources[0], m.Datasources[1] if first.Names.Key != "tag" || second.Names.Key != "tag_v7" { t.Fatalf("colliding keys = %q, %q; want tag, tag_v7", first.Names.Key, second.Names.Key) } diff --git a/internal/intermediate_representation/intermediate_representation_test.go b/internal/intermediate_representation/intermediate_representation_test.go index ba69c1c..0059a60 100644 --- a/internal/intermediate_representation/intermediate_representation_test.go +++ b/internal/intermediate_representation/intermediate_representation_test.go @@ -209,7 +209,7 @@ components: x-tfpfgen-valid-configuration: discriminator: mode variants: - standard: [count] + standard: [quantity] custom: [proxyHost] properties: name: {type: string} @@ -232,12 +232,12 @@ components: notes: type: string x-tfpfgen-silently-ignored-on-update: true - count: + quantity: type: integer x-tfpfgen-valid-when: {property: mode, equals: standard} ratio: type: number - x-tfpfgen-depends-on: {requires: count} + x-tfpfgen-depends-on: {requires: quantity} enabled: {type: boolean} labels: type: array diff --git a/internal/providergen/curated_test.go b/internal/providergen/curated_test.go index b7ed9c5..ee8db2a 100644 --- a/internal/providergen/curated_test.go +++ b/internal/providergen/curated_test.go @@ -89,8 +89,11 @@ func TestUnit_Run_CuratedFixtureGeneratesTheCompleteTree(t *testing.T) { t.Fatalf("Run: %v", err) } - if res.Resources != 3 || res.Datasources != 4 || res.ListResources != 1 || res.Actions != 1 { - t.Errorf("entity counts = %d resources, %d datasources, %d list resources, %d actions; the fixture declares 3, 4, 1, 1", + // transit is enumerable and not addressable, so it is a + // datasource; the three resources are all enumerable, so each + // carries a list resource of its own terraform type. + if res.Resources != 3 || res.Datasources != 5 || res.ListResources != 3 || res.Actions != 1 { + t.Errorf("entity counts = %d resources, %d datasources, %d list resources, %d actions; the fixture declares 3, 5, 3, 1", res.Resources, res.Datasources, res.ListResources, res.Actions) } for _, r := range res.Removals { @@ -112,7 +115,8 @@ func TestUnit_Run_CuratedFixtureGeneratesTheCompleteTree(t *testing.T) { "internal/services/resources/beacons/v1/beacon/resource.go", "internal/services/resources/docks/v1/dock/resource.go", "internal/services/datasources/permits/v1/permit/datasource.go", - "internal/services/list-resources/transits/v1/transit/list_resource.go", + "internal/services/datasources/transits/v1/transit/datasource.go", + "internal/services/list-resources/modules/v1/module/list_resource.go", "internal/services/actions/modules/v1/modules_reboot/action.go", } { if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(path))); err != nil { diff --git a/internal/specmodel/classify.go b/internal/specmodel/classify.go index ceba1dc..2cb3aff 100644 --- a/internal/specmodel/classify.go +++ b/internal/specmodel/classify.go @@ -193,11 +193,21 @@ func (e *entity) decide() (Classification, *Exclusion) { listOnlyShape := e.list != nil && e.read == nil // A collection-path GET whose response carries no array is not a // collection: it is one object at a fixed path — an account's - // preferences, a tenant's enrolment settings. One pilot answers 85 of - // its collection GETs that way, and every one was classified as a list - // resource and then failed for having no elements to reach. - singletonShape := listOnlyShape && !carriesCollection(e.list.SuccessSchema()) - listOK := listOnlyShape && !singletonShape && e.list.SuccessSchema() != nil + // preferences, a tenant's enrolment settings. Classified as a + // collection it has no elements to reach and fails for saying so. + singletonShape := listOnlyShape && e.list.SuccessSchema() != nil && !carriesCollection(e.list.SuccessSchema()) + // A collection the API enumerates but cannot address one member of. It + // is a datasource: terraform can read the collection and nothing else. + collectionOK := listOnlyShape && !singletonShape && e.list.SuccessSchema() != nil + + // A list resource is the list capability of a managed resource, not a + // kind of entity: terraform matches it to a resource by type name, and + // its results are the identities of that resource's live objects. So it + // exists exactly where a resource does and the API can enumerate it. + // + // An entity the API enumerates but cannot address one member of yields + // no resource to match, and is a datasource above. + listOK := resourceOK && e.list != nil && e.list.SuccessSchema() != nil // A singleton is a resource when the API lets it be written: read at the // fixed path, updated there, neither created nor destroyed. Terraform @@ -219,8 +229,10 @@ func (e *entity) decide() (Classification, *Exclusion) { // A resource is always also readable by id, so it yields a // datasource whether or not the API can list it. An entity // whose only access is the item GET yields one too: the - // caller supplies the item path key and reads the object. - if resourceOK || dsOK || lookupOK { + // caller supplies the item path key and reads the object. So + // does one the API only enumerates, which is readable in bulk + // and addressable no other way. + if resourceOK || dsOK || lookupOK || collectionOK { kinds = append(kinds, k) } case KindListResource: @@ -239,7 +251,7 @@ func (e *entity) decide() (Classification, *Exclusion) { Key: e.key, CollectionPath: e.collection, ItemPath: e.item, - Reason: e.exclusionReason(resourceShape, dsShape, listOnlyShape, lookupShape), + Reason: e.exclusionReason(resourceShape, dsShape, listOnlyShape, singletonShape, lookupShape), } } @@ -276,12 +288,17 @@ func (e *entity) decide() (Classification, *Exclusion) { // exclusionReason says why nothing fit, most specific first: a shape that // matched but lacked schemas beats a recital of what was missing. -func (e *entity) exclusionReason(resourceShape, dsShape, listOnlyShape, lookupShape bool) string { +func (e *entity) exclusionReason(resourceShape, dsShape, listOnlyShape, singletonShape, lookupShape bool) string { switch { case resourceShape: return "create, read and delete are present but the create request or read success response declares no schema" case dsShape: return "list and read are present but a success response schema is missing" + case singletonShape: + // One object at a fixed path, and the API offers no way to write + // it. Its schema is present — a missing one is the listOnlyShape + // case below — so what it lacks is a write, not a declaration. + return "one object at a fixed path with no operation that writes it, so terraform would own nothing" case listOnlyShape: return "list is present but its success response declares no schema" case lookupShape: diff --git a/internal/specmodel/classify_test.go b/internal/specmodel/classify_test.go index be6bf5f..892a735 100644 --- a/internal/specmodel/classify_test.go +++ b/internal/specmodel/classify_test.go @@ -122,9 +122,9 @@ func TestUnit_Specmodel_ClassifyTable(t *testing.T) { lookupByKey map[string]bool }{ { - name: "full lifecycle is a resource and a datasource", + name: "a full lifecycle the api can enumerate is a resource, a datasource and a list resource", paths: crud("/tags", "get!", "post!") + crud("/tags/{tagId}", "get!", "patch!", "delete"), - wantKinds: map[string]string{"tag": "resource,datasource"}, + wantKinds: map[string]string{"tag": "resource,datasource,list-resource"}, missingUpdate: map[string]bool{"tag": false}, }, { @@ -155,9 +155,9 @@ func TestUnit_Specmodel_ClassifyTable(t *testing.T) { lookupByKey: map[string]bool{"region": false}, }, { - name: "list without an item get is a list-resource", + name: "a collection with no item get is a datasource, because no resource can match a list resource", paths: crud("/metrics", "get!"), - wantKinds: map[string]string{"metric": "list-resource"}, + wantKinds: map[string]string{"metric": "datasource"}, }, { name: "item get without a list is a datasource looked up by the item path key", @@ -190,6 +190,19 @@ func TestUnit_Specmodel_ClassifyTable(t *testing.T) { paths: crud("/blips", "get"), wantExcluded: map[string]string{"blip": "success response declares no schema"}, }, + { + // A list resource is matched to a resource by type name, so it + // cannot exist without one. An entity the API enumerates but + // cannot create is a datasource and nothing more. + name: "a collection the api cannot create yields no list resource", + paths: crud("/motes", "get!") + crud("/motes/{moteId}", "get!"), + wantKinds: map[string]string{"mote": "datasource"}, + }, + { + name: "a resource the api cannot enumerate yields no list resource", + paths: crud("/specks", "post!") + crud("/specks/{speckId}", "get!", "delete"), + wantKinds: map[string]string{"speck": "resource,datasource"}, + }, { name: "list and read without response schemas are excluded", paths: crud("/dims", "get") + crud("/dims/{dimId}", "get"), @@ -377,7 +390,7 @@ func TestUnit_Specmodel_ABareParameterPathIsItsOwnEntity(t *testing.T) { } got := Classify(doc) if len(got.Entities) != 1 || got.Entities[0].Key != "root" || - kinds(got.Entities[0]) != "list-resource" { + kinds(got.Entities[0]) != "datasource" { t.Fatalf("entities = %+v, excluded = %+v", got.Entities, got.Excluded) } } @@ -416,7 +429,7 @@ func TestUnit_Specmodel_DefaultResponseBacksSuccess(t *testing.T) { t.Fatalf("Load: %v", err) } got := Classify(doc) - if len(got.Entities) != 1 || kinds(got.Entities[0]) != "list-resource" { + if len(got.Entities) != 1 || kinds(got.Entities[0]) != "datasource" { t.Fatalf("entities = %+v, excluded = %+v", got.Entities, got.Excluded) } }