Generics and hooks in a generated terraform provider
Every AWX quirk used to mean another branch in a template. Now it's a field on a config struct.
The problem with generating code is that it works, so you keep generating more of it, and after a while nobody is reading any of it.
Every resource in the AWX provider had nine functions on it, Configure, Metadata, Schema, ImportState, Create, Read, Update, Delete, and a constructor.
Of those, exactly one is different per resource, and it’s the schema.
That’s fine right up until AWX does something odd, and AWX does something odd fairly often.
Some endpoints don’t have an ID.
Some can’t be deleted.
Some are created at one URL and managed at another.
Some return a placeholder instead of the secret you just sent, so terraform sees a permanent diff unless you tell it not to read that field back.
Every one of those meant another branch in a template, and a template with branches in it generating code with branches in it is not something I wanted to keep extending.
So the eight identical functions moved into one generic type, and the odd cases became configuration instead of generated code.
type GenericResource[T any, B any, PT ResourceModel[T, B]] struct { ResourceBase Cfg ResourceCfg[T, B]}T is the terraform model and B is the request body, which is the boring half of the signature.
PT is there because Go can’t say “T whose pointer has these methods”, so we take the pointer type as its own parameter and constrain that.
type DataModel[T any] interface { *T Clone() T UpdateFromApiData(data map[string]any) (diag.Diagnostics, error)}The *T line is the whole trick. It’s what lets the generic code call methods with pointer receivers on a value it was handed.
So the interesting half of this is the config, because that’s where all the odd cases went.
An instance can’t be deleted with DELETE because AWX answers 405, and what actually removes it is moving node_state across to deprovisioning, so that one is a soft delete and it’s a single field.
SoftDelete: map[string]any{"node_state": "deprovisioning"},A settings resource has no ID at all, so the create is a PATCH and the endpoint never gets an ID appended to it.
NoId: true,A credential needs its type ID, which isn’t on the plan, it’s looked up from the API once at configure time and injected into the body on the way out.
OnConfigure: func(...) { /* look it up, close over it */ },MutateBody: func(plan *T, body *B) { body.CredentialType = cached },A job template that waits for the job to finish polls a field until it reaches a terminal value, and the timeouts block in the schema comes along with it automatically.
Write only fields get two functions, one that copies them into the request and one that copies them into state, because the API will never hand them back.
None of that is generated any more, it’s a struct literal sitting in the constructor, nil when the resource doesn’t need it, and the generic code checks for nil before it does anything.
When AWX does the next odd thing it’s a field on ResourceCfg, a nil check in one place, and a line in whichever resource needs it, rather than a new branch in a template that then runs against every resource in the provider.