Documentation
¶
Overview ¶
Package tfclass reads a Terraform plan and says which of its changes billet's deployment has to be drained for.
TERRAFORM ALREADY CLASSIFIES ITS OWN CHANGES — create, update, replace, destroy — and that is not the question an operator has. ADR-004 keeps live billet nodes outside Terraform on purpose, so a plan cannot know that these hosts are running somebody's build, and "1 to change, 1 to destroy" is therefore the same sentence whether the change is a tag or the instance holding the ledger.
The missing half is committed beside the module as classification.json, and this joins the two. It is deliberately a separate reader rather than logic in HCL: an output cannot see a plan, and a `check` block cannot fail one for a reason it was not given.
WHAT IT REFUSES IS THE POINT. `prevent_destroy` on the ledger volume already fails a plan that would destroy it, which covers exactly one resource; this covers the rest, and covers the class Terraform has no vocabulary for at all — a change that is perfectly ordinary except that a machine has to stop for it.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
Types ¶
type Class ¶
type Class string
Class is what a change to a resource costs a running deployment.
ORDERED, and the order is what lets a plan's own actions be folded in: a destroy of a resource this table calls in_place is still a destroy, so the effective class is the WORSE of the two. Erring high costs an operator a drain they did not need; erring low costs somebody's build.
const ( // InPlace means nothing running is disturbed. InPlace Class = "in_place" // Replacement means the resource is recreated. No running job is on it, but // there is a window in which it does not exist. Replacement Class = "replacement" // Draining means a host that may be running jobs stops, or loses something a // running job depends on. billet's durable drain comes first — and a // Terraform timeout is not permission to terminate a job. Draining Class = "draining" // Destructive means data does not come back. Destructive Class = "destructive" )
func (Class) NeedsDrain ¶
NeedsDrain reports whether acting on this class without draining first can end somebody's job or lose data.
type Entry ¶
type Entry struct {
Class Class `json:"class"`
// Reason is why, in the operator's terms rather than Terraform's.
Reason string `json:"reason"`
// Remedy is the billet command that makes the change safe, where one exists.
Remedy string `json:"remedy,omitempty"`
}
Entry is what the committed table says about one resource.
type Finding ¶
type Finding struct {
Address string
Actions []string
Class Class
Entry Entry
// OutOfScope marks a change in a module this table does not describe. It is
// REPORTED rather than dropped, and never blocks: billet has nothing to say
// about somebody else's resource, and saying nothing at all about it would
// leave a plan reader believing the report covered the whole plan.
OutOfScope bool
}
Finding is one classified change.
func Blocking ¶
Blocking returns the findings that must not be applied without draining first.
AN OUT-OF-SCOPE CHANGE NEVER BLOCKS. billet has nothing to say about a resource in somebody else's module, and blocking on one would be this gate asserting authority over a configuration it has never seen.
func Classify ¶
Classify reads a plan and returns everything it will actually do, worst first.
NO-OPS AND READS ARE NOT CHANGES and are dropped, so a report names what an apply would do rather than restating the whole state. DATA SOURCES ARE DROPPED too: a `read` is not an action on infrastructure, and classifying one would require an entry for every lookup.
AN UNCLASSIFIED RESOURCE IS AN ERROR, NEVER A DEFAULT. Treating it as in_place would make a resource somebody forgot to classify the one a plan says nothing about — a gate that passes precisely where it has never been taught to look.
type Plan ¶
type Plan struct {
// FormatVersion is the plan format's own version. Terraform documents that a
// consumer must reject an unsupported MAJOR.
FormatVersion string `json:"format_version"`
// ResourceChanges is a POINTER so absent and empty can be told apart. An
// empty array is an ordinary no-op plan; an absent key means this is not a
// plan at all, which is exactly what a state file looks like here.
ResourceChanges *[]ResourceChange `json:"resource_changes"`
// Errored says the plan itself failed. Terraform emits one, and classifying a
// plan Terraform could not finish is answering a question nobody has.
Errored bool `json:"errored"`
}
Plan is the subset of `terraform show -json` this reads.
DECLARED NARROWLY ON PURPOSE. The plan format is somebody else's and carries far more than this needs; decoding only these fields means a format that grows cannot change what this concludes.
BUT NARROW DECODING IS NOT PERMISSION TO ACCEPT ANYTHING. `terraform show -json` renders STATE as well as a plan, and a state file — or `{}`, or a future format this build does not understand — decodes into this struct perfectly, with zero changes, and would be reported as a plan that needs no drain. A gate that answers "safe" about input it never understood is worse than no gate, so the three fields below exist to refuse rather than to be read.
type ResourceChange ¶
type ResourceChange struct {
Address string `json:"address"`
// ModuleAddress is empty for a resource in the root module, and
// `module.<name>` (nested with dots) otherwise. It is what Scope compares.
ModuleAddress string `json:"module_address"`
Mode string `json:"mode"`
Type string `json:"type"`
Name string `json:"name"`
Change struct {
Actions []string `json:"actions"`
} `json:"change"`
}
ResourceChange is one planned change.
func (ResourceChange) Key ¶
func (r ResourceChange) Key() string
Key is the classification key for this change.
type Scope ¶
type Scope string
Scope bounds which of a plan's resources this table describes.
THE TABLE IS KEYED BY type.name, AND THOSE NAMES ARE NOT UNIQUE IN SOMEBODY ELSE'S ROOT. `aws_iam_role.node` means "the identity a billet node runs as" here and could mean anything in a consumer's own configuration — so a plan for a root that merely CONTAINS billet would have an unrelated resource inherit billet's classification, and the generic names are exactly the ones a collision is likely on.
An empty Scope means the plan IS the billet module — the documented invocation, where every managed resource is billet's and must be classified. A non-empty one names the module address billet was called at (`module.billet`), and anything outside it is reported as OUT OF SCOPE rather than silently skipped: a resource this table has nothing to say about is a fact the operator needs, not one to omit.
type Table ¶
Table maps a resource's type.name to what changing it costs.
KEYED BY type.name RATHER THAN BY FULL ADDRESS, because the same resource means the same thing in whichever module declares it — the node role exists in both fleet-ec2 and fleet-codebuild and is the identity a running instance or build carries either way. A full address would also make every entry depend on what the root happens to call its children, so moving a module would silently empty the table.
func Load ¶
Load reads the committed table.
IT VALIDATES EVERY ENTRY RATHER THAN THE ONES A PLAN HAPPENS TO TOUCH. A class nobody recognises, or an entry with no reason, is a table somebody edited without finishing — and finding that out only when a plan reaches that resource means finding out during the apply it was meant to gate.