Skip to content

Commit f23ab4c

Browse files
committed
[TFgen] Implement tracking-field decoder + duplicate artifact name detection (#3853)
* Implement tracking-field decoder + duplicate artifact name detection * Fixed potential errors with missing skip values. * Support for ignoring duplicates if they have different kinds. * comment/contracts cleanup * removed redundant testing while keeping the same coverage
1 parent cd92c73 commit f23ab4c

16 files changed

Lines changed: 981 additions & 6 deletions

.generator-v2/go.mod

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ toolchain go1.26.1
66

77
require (
88
github.com/pb33f/libopenapi v0.37.2
9+
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
910
github.com/spf13/cobra v1.10.2
11+
go.yaml.in/yaml/v4 v4.0.0-rc.4
1012
)
1113

1214
require (
@@ -16,6 +18,6 @@ require (
1618
github.com/pb33f/jsonpath v0.8.2 // indirect
1719
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
1820
github.com/spf13/pflag v1.0.9 // indirect
19-
go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect
2021
golang.org/x/sync v0.20.0 // indirect
22+
golang.org/x/text v0.14.0 // indirect
2123
)

.generator-v2/go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx2
55
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
66
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
77
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
8+
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
9+
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
810
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
911
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
1012
github.com/pb33f/jsonpath v0.8.2 h1:Ou4C7zjYClBm97dfZjDCjdZGusJoynv/vrtiEKNfj2Y=
@@ -16,6 +18,8 @@ github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7
1618
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
1719
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
1820
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
21+
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
22+
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
1923
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
2024
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
2125
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
@@ -27,6 +31,8 @@ go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
2731
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
2832
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
2933
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
34+
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
35+
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
3036
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
3137
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
3238
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

.generator-v2/internal/cli/generate.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ func newGenerateCmd(flags *globalFlags) *cobra.Command {
1414
Use: "generate",
1515
Short: "Generate Terraform artifacts from the OpenAPI spec",
1616
RunE: func(cmd *cobra.Command, args []string) error {
17-
spec, err := parser.LoadSpec(flags.spec, parser.WithMaxDepth(flags.maxDepth))
17+
spec, err := parser.LoadSpec(flags.spec,
18+
parser.WithMaxDepth(flags.maxDepth),
19+
parser.WithTrackingFieldName(flags.trackingField))
1820
if err != nil {
1921
return err
2022
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Package contracts holds the machine-readable contracts the generator
2+
// validates against. The files are embedded so the tfgen binary is
3+
// self-contained and never depends on its working directory at runtime.
4+
//
5+
// New contracts (e.g. a CLI-flag doc or a run-report schema) are added here as
6+
// another //go:embed directive plus an exported var.
7+
package contracts
8+
9+
import _ "embed"
10+
11+
// TrackingFieldSchema is the embedded JSON Schema (draft 2020-12) for the
12+
// x-datadog-tf-generator OpenAPI vendor extension. parser.DecodeTracking
13+
// compiles it once and validates every extension against it.
14+
//
15+
//go:embed tracking-field.schema.json
16+
var TrackingFieldSchema []byte
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://datadog.github.io/terraform-provider-datadog/contracts/tracking-field.schema.json",
4+
"title": "x-datadog-tf-generator",
5+
"description": "OpenAPI 3.0 vendor extension that opts an operation in to Datadog Terraform provider generation.",
6+
"type": "object",
7+
"additionalProperties": false,
8+
"required": ["artifact_kind", "artifact_name"],
9+
"properties": {
10+
"artifact_kind": {
11+
"type": "string",
12+
"enum": ["resource", "data_source"],
13+
"description": "Whether this operation should be exposed as a Terraform resource (with full CRUD lifecycle) or a Terraform data source (read-only)."
14+
},
15+
"artifact_name": {
16+
"type": "string",
17+
"pattern": "^[a-z][a-z0-9_]*$",
18+
"minLength": 1,
19+
"maxLength": 64,
20+
"description": "Terraform-facing artifact name without the 'datadog_' prefix. Must be lowercase snake_case. Must be unique per artifact_kind — resources and data sources are separate Terraform namespaces, so a resource and a data source may share a name."
21+
},
22+
"group": {
23+
"type": "object",
24+
"description": "Declares which OpenAPI operations form the C/R/U/D quadruple. May reference operations by their operationId.",
25+
"additionalProperties": false,
26+
"properties": {
27+
"create": { "type": "string", "description": "operationId of the Create endpoint." },
28+
"read": { "type": "string", "description": "operationId of the Read endpoint." },
29+
"update": { "type": "string", "description": "operationId of the Update endpoint. May be omitted; the generator will then mark all attributes ForceNew per the missing-CRUD edge case in the spec." },
30+
"delete": { "type": "string", "description": "operationId of the Delete endpoint." }
31+
},
32+
"required": ["read"]
33+
},
34+
"id_strategy": {
35+
"type": "string",
36+
"enum": ["data.id", "data.attributes.id", "data.attributes.uuid", "header.location"],
37+
"default": "data.id",
38+
"description": "How to derive the Terraform resource ID from the API response on Create/Read."
39+
},
40+
"sensitive": {
41+
"type": "boolean",
42+
"default": false,
43+
"description": "When attached to a Schema Object, marks the attribute as Terraform-sensitive (suppressed in plan output)."
44+
},
45+
"skip": {
46+
"type": "boolean",
47+
"default": false,
48+
"description": "Explicitly disable generation for this operation while keeping the annotation in place. Equivalent to removing the extension, but documented in-spec so reviewers see the choice."
49+
}
50+
},
51+
"examples": [
52+
{
53+
"artifact_kind": "data_source",
54+
"artifact_name": "team",
55+
"group":{
56+
"read": "GetTeam"
57+
}
58+
},
59+
{
60+
"artifact_kind": "resource",
61+
"artifact_name": "incident_type",
62+
"group": {
63+
"create": "CreateIncidentType",
64+
"read": "GetIncidentType",
65+
"update": "UpdateIncidentType",
66+
"delete": "DeleteIncidentType"
67+
},
68+
"id_strategy": "data.id"
69+
}
70+
]
71+
}
72+

.generator-v2/internal/model/tracking.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ type TrackingFieldMetadata struct {
1111
// Required.
1212
ArtifactKind ArtifactKind `json:"artifact_kind"`
1313
// ArtifactName is the Terraform-facing name without the datadog_ prefix,
14-
// lowercase snake_case, unique across the spec. Required.
14+
// lowercase snake_case, unique per artifact_kind (resources and data
15+
// sources are separate Terraform namespaces). Required.
1516
ArtifactName string `json:"artifact_name"`
1617
// Group declares which operations form the C/R/U/D quadruple. Required for
1718
// resources; for data sources only Read is meaningful.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package parser
2+
3+
import (
4+
"fmt"
5+
"sort"
6+
"strings"
7+
8+
"github.com/terraform-providers/terraform-provider-datadog/generator/internal/model"
9+
)
10+
11+
// OperationLocation identifies one operation participating in an artifact_name
12+
// collision.
13+
type OperationLocation struct {
14+
Path string
15+
Method string
16+
OperationId string
17+
}
18+
19+
// ArtifactNameCollision records every operation that declared a given
20+
// (kind, artifact_name) pair, when more than one did. Uniqueness is scoped per
21+
// kind: Terraform resources and data sources occupy separate namespaces, so a
22+
// datadog_team resource and a datadog_team data source may coexist.
23+
type ArtifactNameCollision struct {
24+
Kind model.ArtifactKind
25+
Name string
26+
Sources []OperationLocation
27+
}
28+
29+
// DuplicateArtifactNameError aggregates every (kind, artifact_name) collision
30+
// found across the spec into a single error, naming all source locations for
31+
// each. Collisions are sorted by (name, kind) and their sources by
32+
// (path, method), so the message is deterministic regardless of input order.
33+
type DuplicateArtifactNameError struct {
34+
Collisions []ArtifactNameCollision
35+
}
36+
37+
func (e *DuplicateArtifactNameError) Error() string {
38+
var b strings.Builder
39+
b.WriteString("parser: duplicate artifact_name across operations:")
40+
for _, c := range e.Collisions {
41+
fmt.Fprintf(&b, "\n %s %q declared by:", c.Kind, c.Name)
42+
for _, s := range c.Sources {
43+
fmt.Fprintf(&b, "\n - %s %s (operationId %q)", s.Method, s.Path, s.OperationId)
44+
}
45+
}
46+
return b.String()
47+
}
48+
49+
// CheckDuplicateArtifactNames reports every (kind, artifact_name) pair claimed
50+
// by more than one operation. Uniqueness is scoped per kind — Terraform
51+
// resources and data sources are separate namespaces, so the same name under
52+
// different kinds is allowed. Operations without tracking metadata are ignored
53+
// (they generate no artifact and so cannot collide). It returns a single
54+
// aggregated *DuplicateArtifactNameError naming every collision and all of its
55+
// sources, or nil when every name is unique within its kind.
56+
func CheckDuplicateArtifactNames(spec *model.Spec) error {
57+
// Key on (kind, name) rather than name alone. Keying on the kind value
58+
// itself — not an `== "resource"` special case — keeps this correct if new
59+
// artifact kinds are ever introduced.
60+
type artifactKey struct {
61+
Kind model.ArtifactKind
62+
Name string
63+
}
64+
byKey := make(map[artifactKey][]OperationLocation)
65+
for _, op := range spec.Operations {
66+
if op == nil || op.Tracking == nil {
67+
continue
68+
}
69+
key := artifactKey{Kind: op.Tracking.ArtifactKind, Name: op.Tracking.ArtifactName}
70+
byKey[key] = append(byKey[key], OperationLocation{
71+
Path: op.Path,
72+
Method: op.Method,
73+
OperationId: op.OperationId,
74+
})
75+
}
76+
77+
var collisions []ArtifactNameCollision
78+
for key, locs := range byKey {
79+
if len(locs) < 2 {
80+
continue
81+
}
82+
sort.Slice(locs, func(i, j int) bool {
83+
if locs[i].Path != locs[j].Path {
84+
return locs[i].Path < locs[j].Path
85+
}
86+
return locs[i].Method < locs[j].Method
87+
})
88+
collisions = append(collisions, ArtifactNameCollision{Kind: key.Kind, Name: key.Name, Sources: locs})
89+
}
90+
if len(collisions) == 0 {
91+
return nil
92+
}
93+
sort.Slice(collisions, func(i, j int) bool {
94+
if collisions[i].Name != collisions[j].Name {
95+
return collisions[i].Name < collisions[j].Name
96+
}
97+
return collisions[i].Kind < collisions[j].Kind
98+
})
99+
return &DuplicateArtifactNameError{Collisions: collisions}
100+
}

0 commit comments

Comments
 (0)