tfclass

package
v0.10.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 11, 2026 License: Apache-2.0 Imports: 7 Imported by: 0

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

func Report

func Report(findings []Finding) string

Report renders findings for a person.

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 Classes

func Classes() []Class

Classes lists every class, mildest first, for a diagnostic.

func (Class) NeedsDrain

func (c Class) NeedsDrain() bool

NeedsDrain reports whether acting on this class without draining first can end somebody's job or lose data.

func (Class) Valid

func (c Class) Valid() bool

Valid reports whether c is a class this build understands.

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

func Blocking(findings []Finding) []Finding

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

func Classify(table Table, scope Scope, planJSON []byte) ([]Finding, error)

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

type Table map[string]Entry

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

func Load(path string) (Table, error)

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL