Skip to content

Commit 66dbc5b

Browse files
chiraukiclaude
andauthored
Fix inconsistent-apply-result errors for tsb_oidc and tsb_team (#16)
## Summary - `tsb_oidc.secret` is proto `INPUT_ONLY` (accepted on write, never echoed back). `flatten<Resource>` was unconditionally overwriting the model with the server's always-empty response for such fields, producing "Provider produced inconsistent result after apply" on every create/update. Fixed generically in `cmd/gen-provider` by detecting `google.api.field_behavior = INPUT_ONLY` and skipping the flatten assignment. - `tsb_team.source_type` is Computed-only with a `Default` that falls back to the proto enum zero value (`"INVALID"`) because it's missing the upstream `enumdefault` override tag its sibling `User.source_type` has. TSB's `CreateTeam`/`UpdateTeam` always coerce an unset source type to `LOCAL` server-side, so the planned `"INVALID"` never matches the server's real response. Fixed in `internal/provider/schema_fixup.go`: Computed-only string attributes whose Default resolves to the enum zero-value sentinel now drop the Default in favor of `UseStateForUnknown`, so the plan value stays unknown until the server's actual value is known. `User.source_type`'s legitimate `MANUAL` default is untouched. ## Test plan - [x] `go build ./...` - [x] `go test ./...` - [x] `make format` (clean diff) - [x] Ad hoc test confirming `fixupSchema(TeamSchema())` drops the Default + adds `UseStateForUnknown` on `source_type`, while `fixupSchema(UserSchema())` leaves its `MANUAL` default untouched - [ ] `terraform apply` against a live TSB instance with `tsb_oidc` + `tsb_team` resources (not run here — requires live infra) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent b62d0f2 commit 66dbc5b

4 files changed

Lines changed: 66 additions & 4 deletions

File tree

cmd/gen-provider/emit.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,16 @@ import (
88
"os/exec"
99
"path/filepath"
1010
"reflect"
11+
"slices"
1112
"sort"
1213
"strconv"
1314
"strings"
1415

1516
j "github.com/dave/jennifer/jen"
17+
"google.golang.org/genproto/googleapis/api/annotations"
18+
"google.golang.org/protobuf/proto"
1619
"google.golang.org/protobuf/reflect/protoreflect"
20+
"google.golang.org/protobuf/types/descriptorpb"
1721
)
1822

1923
// Framework import paths used by the generated code.
@@ -504,3 +508,22 @@ var createOnlyMessages = map[string]bool{
504508
func isCreateOnly(fd protoreflect.FieldDescriptor) bool {
505509
return isMessageKind(fd) && createOnlyMessages[string(fd.Message().FullName())]
506510
}
511+
512+
// isInputOnly reports whether a field is annotated `google.api.field_behavior =
513+
// INPUT_ONLY`: the server accepts it on write but never returns it in responses
514+
// (e.g. OIDC.secret, a client secret TSB stores outside its database and never
515+
// echoes back). Such fields must not be overwritten by flatten — the model
516+
// already holds the correct value, either from the plan (create/update) or the
517+
// prior state (read) — or every apply would report a produced-inconsistent-state
518+
// error as the server's always-empty response blanks out the configured value.
519+
func isInputOnly(fd protoreflect.FieldDescriptor) bool {
520+
opts, ok := fd.Options().(*descriptorpb.FieldOptions)
521+
if !ok {
522+
return false
523+
}
524+
behaviors, ok := proto.GetExtension(opts, annotations.E_FieldBehavior).([]annotations.FieldBehavior)
525+
if !ok {
526+
return false
527+
}
528+
return slices.Contains(behaviors, annotations.FieldBehavior_INPUT_ONLY)
529+
}

cmd/gen-provider/emit_conv.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,12 @@ func (g *generator) flattenField(p fieldPlan) ([]j.Code, error) {
265265
}, nil
266266
}
267267

268+
// INPUT_ONLY fields are never echoed back by the server; leave the model's
269+
// existing value (plan or prior state) untouched instead of overwriting it.
270+
if isInputOnly(fd) {
271+
return nil, nil
272+
}
273+
268274
switch {
269275
case fd.IsMap():
270276
if isMessageKind(fd.MapValue()) {

internal/provider/models_gen.go

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/provider/schema_fixup.go

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,24 @@
33
package provider
44

55
import (
6+
"context"
7+
68
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
9+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults"
10+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
711
)
812

13+
// enumZeroValueDefault is the sentinel a proto enum's zero value renders as
14+
// (e.g. SourceType_INVALID = 0 -> "INVALID"). protoc-gen-terraform falls back
15+
// to this name as a Computed attribute's Default whenever the proto field is
16+
// missing a `+protoc-gen-terraform:enumdefault:N` override tag — it is never a
17+
// deliberately chosen default. See defaultIsUntrustworthy.
18+
const enumZeroValueDefault = "INVALID"
19+
920
// fixupSchema adapts a reused TSB schema (produced by protoc-gen-terraform) so it
1021
// is valid and well-behaved for the Terraform Plugin Framework at serve time.
1122
//
12-
// Two adjustments are made, recursively, to every attribute:
23+
// Adjustments are made, recursively, to every attribute:
1324
//
1425
// 1. Required attributes carrying a Default (the enum StaticString default the
1526
// schema generator emits) have the Default dropped — Required+Default is
@@ -28,11 +39,30 @@ import (
2839
// made Computed: the generated models back them with plain Go structs, which
2940
// cannot represent the "unknown" plan value that Computed introduces. Their
3041
// leaf attributes are still fixed up via recursion.
42+
//
43+
// 3. Computed-only (not Optional/Required) string attributes whose Default
44+
// resolves to the proto enum zero-value sentinel have the Default dropped
45+
// in favor of UseStateForUnknown. Such a Default is protoc-gen-terraform's
46+
// "no real default configured" fallback, not a value TSB actually persists
47+
// (e.g. Team.source_type defaults to "INVALID", but CreateTeam/UpdateTeam
48+
// always coerce an unset source type to LOCAL server-side) — keeping it
49+
// forces a known planned value that the server's real response then
50+
// contradicts, producing "Provider produced inconsistent result after
51+
// apply" on every create. Leaving the planned value unknown lets it be
52+
// filled in from whatever the server actually returns.
3153
func fixupSchema(s schema.Schema) schema.Schema {
3254
s.Attributes = fixupAttributes(s.Attributes)
3355
return s
3456
}
3557

58+
// defaultIsUntrustworthy reports whether d resolves to the proto enum
59+
// zero-value sentinel (see enumZeroValueDefault).
60+
func defaultIsUntrustworthy(d defaults.String) bool {
61+
var resp defaults.StringResponse
62+
d.DefaultString(context.Background(), defaults.StringRequest{}, &resp)
63+
return resp.PlanValue.ValueString() == enumZeroValueDefault
64+
}
65+
3666
func fixupAttributes(in map[string]schema.Attribute) map[string]schema.Attribute {
3767
out := make(map[string]schema.Attribute, len(in))
3868
for name, attr := range in {
@@ -44,10 +74,14 @@ func fixupAttributes(in map[string]schema.Attribute) map[string]schema.Attribute
4474
func fixupAttribute(attr schema.Attribute) schema.Attribute {
4575
switch a := attr.(type) {
4676
case schema.StringAttribute:
47-
if a.Required {
77+
switch {
78+
case a.Required:
4879
a.Default = nil
49-
} else if a.Optional {
80+
case a.Optional:
5081
a.Computed = true
82+
case a.Computed && a.Default != nil && defaultIsUntrustworthy(a.Default):
83+
a.Default = nil
84+
a.PlanModifiers = append(a.PlanModifiers, stringplanmodifier.UseStateForUnknown())
5185
}
5286
return a
5387
case schema.BoolAttribute:

0 commit comments

Comments
 (0)