Documentation
¶
Overview ¶
Package dropguard decides whether dropping a table is safe by MEASURING the table, not by arguing about it.
Why it exists ¶
Every table this repo has ever dropped was dropped on the strength of a prose paragraph in the migration: "nothing ever seeded this", "no tenant wrote here", "the join table went in an earlier migration rather than being copied". Each of those sentences was true when it was written. None of them is checked, so none of them stays true on its own, and the moment one stops being true nothing says so — the migration still runs, and the rows still go.
A claim about data that no one counts is not a safety property. It is a note.
What a decision looks like here ¶
A drop is admissible when three things hold, and all three are established by reading the migrations and the database rather than by reading a comment:
declared every DROP TABLE in an Up section has an entry in the service's
dropguard.json naming the exact number of rows it expects to destroy.
A drop nobody declared is a finding, so the number cannot be skipped;
an entry with no drop behind it is also a finding, so the declaration
expires by itself when the migration it describes is gone.
measured the row count is read from the database at the version immediately
before the drop, and compared for EQUALITY with the declared number.
Not "at most", not "roughly": a table that grew a row nobody accounted
for is exactly the case this exists to catch.
grounded a declaration expecting rows must point at the migration that INSERTs
them, and that INSERT is looked up in the parsed migrations. An
expectation of rows in a table no migration ever writes to contradicts
itself, and is refused.
Its own precondition ¶
The measurement refuses to produce a number it did not observe. If the database cannot be reached, or the table is not there to be counted, Observe returns ErrNoConnection or ErrTableAbsent — never (0, nil). This is the whole point: a guard that reports "zero rows" when it never connected is worse than no guard, because it reports the safe answer under precisely the conditions in which it knows nothing. Callers must render those two errors as NOT VERIFIED and refuse the drop, never as clean.
The other database: what a replayed chain cannot know ¶
The measured runs replay the migration chain into an empty database, so the numbers they produce are facts about the chain: what our own migrations seed. A tenant database also holds what tenants wrote, and no test container can know that.
That half is Preflight, reached through Gate, and every migrator in the tree calls it before it applies anything. It counts, on the database in front of it, each table that a migration which has NOT YET RUN — and which THIS RUN WILL REACH — is going to drop. The question there is narrower than equality with a declared number twice over. That number was measured on a different database, and matching it licenses nothing here; and a run that stops at a version cannot destroy anything past it, so refusing over a drop it will not execute would cost an operator an approval for somebody else's table. The question is whether this drop destroys rows AT ALL, and a table that still holds any is refused until an operator names that exact drop.
How far the run goes arrives as Target, whose ZERO VALUE is the whole chain: the caller that forgets gets the widest check, and there is no "count nothing" to reach for — the same reason Approval names a version and a table and no blanket override exists.
Observe is exported for exactly that reason: the same primitive, with the same refusal to guess, serves both halves. Whichever half asks, an unreachable database comes back as ErrNoConnection and never as a count of zero.
The choice, its cost and what an operator does when a deploy stops are recorded once, in docs/architecture/drop-preflight-counts-the-live-database.md, and are deliberately not restated here.
What the reader can see, and what it cannot ¶
Both halves start from the same reading of the migrations, so the forms of drop that reading understands are a property of the whole gate. They are named here because a form nobody named is not judged clean — it is not judged at all, and the difference is invisible from the outside.
Judged. The subject is written down, and the gate reads it:
DROP TABLE kaname.limits; plain, schema-qualified or not DROP TABLE IF EXISTS a, b; every name in a list, each with its own line EXECUTE 'DROP TABLE kaname.limits'; dynamic, but the statement is a LITERAL
The third is worth spelling out because it is easy to assume otherwise: putting a drop inside EXECUTE does not hide it, as long as the name is still written. What hides a drop is the name being COMPUTED, not the EXECUTE around it.
Seen but not judged, and COUNTED as such. The statement is recognisable as a drop, but its subject is assembled at run time, so the file holds no name to read:
EXECUTE format('DROP TABLE %I', t); placeholder
EXECUTE format('DROP TABLE sch.%I', t); qualifier written, leaf computed
EXECUTE 'DROP TABLE ' || quote_ident(t); concatenation
No pattern can close this. The name does not exist until PL/pgSQL builds the string, and a gate that guessed at it would be asserting something it did not read — the exact move this package was written to stop. So these are counted by Inv.UnreadableDrops, and both censuses print them with coordinates. That is the honest position: the gate does not say such a drop is safe, it says it could not read it. The second form would otherwise be WORSE than unread — dropTableRe captures `sch.` and would enter a table that does not exist into the inventory — and it is suppressed for that reason.
Not seen at all. The text `DROP TABLE` is not in the file:
EXECUTE stmt; statement built elsewhere, or read from a query
This one is not in the count either, and saying so is the point of naming it: a number cannot include what nothing detected. Nothing short of executing the migration would reveal it, which is why Preflight counts the live database before a run rather than trusting this reading alone — but a drop built entirely out of values would still pass unremarked, and no part of this package claims otherwise.
The idiom is not hypothetical. This tree runs dynamic DDL in migrations already — DROP INDEX and ALTER TABLE ... DROP CONSTRAINT are built this way — so the first table dropped in that style would land in a form nothing here reads. Whether an unreadable drop should REFUSE a migration rather than be counted is a policy question this package does not settle; it is not settled by silence either, which is why the number is printed even when it is zero.
What neither half covers ¶
Neither is atomic with the drop. The live count is taken seconds before the migration runs, while the old pods are still serving, so a row can arrive in between. That makes the answer recent rather than never-taken; nothing available here would make it simultaneous, and claiming otherwise would be worse than not counting.
Index ¶
- Constants
- Variables
- func AdjudicateDeclaredDropCount(service string, found, filesScanned, declared int) string
- func Gate(ctx context.Context, db Querier, service string, fsys fs.FS, out io.Writer, ...) error
- func ManifestAbsenceIsLegitimate(dropsInChain int, err error) bool
- func Observe(ctx context.Context, db Querier, table string) (int64, error)
- func PreserveCommand(table string) string
- func PreserveSelect(table string) string
- type AppliedSet
- type Approval
- type Counter
- type Declaration
- type Drop
- type DynamicDrop
- type Inv
- type Kind
- type Manifest
- type PreflightReport
- type Querier
- type Report
- type StepTo
- type Target
- type Violation
- type ViolationKind
Constants ¶
const ApprovalEnv = "KACHO_MIGRATOR_DROP_APPROVED"
ApprovalEnv is where an operator writes the drops they have decided to let through. It is a constant so that the reader and the refusal message that tells an operator what to set cannot drift into naming two different variables.
const ManifestName = "dropguard.json"
ManifestName is the file each service keeps beside its migrations, declaring what every drop in them expects to destroy.
Variables ¶
var ( // ErrNoConnection — the database could not be reached, or its catalogue could // not be read. Nothing was measured. ErrNoConnection = errors.New("drop guard reached no database") // ErrTableAbsent — the connection worked and the table is not there. Nothing // was measured either: an absent table has no row count, and reporting zero // would make "already gone" indistinguishable from "safe to drop". ErrTableAbsent = errors.New("drop guard found no such table") )
The two ways a measurement can fail to happen. They are values, not log lines, because a caller must be forced to handle them: the whole failure mode this package exists to prevent is a guard that answers "zero" when it looked at nothing.
Functions ¶
func AdjudicateDeclaredDropCount ¶
AdjudicateDeclaredDropCount compares the number of drops a chain HOLDS with the number its caller DECLARES, and returns the finding, or "" when they agree.
Why this is a function and not four lines inside the harness ¶
While the comparison lived inside the harness it could only be exercised by making a real service's chain wrong, which is to say it could not be exercised at all: a check that cannot be made to fail on purpose is indistinguishable from one that cannot fail. Splitting the JUDGEMENT from the GATHERING is what lets the injection hand it numbers directly.
Why the caller declares a number at all ¶
Because the number cannot move on its own. Reconcile already refuses a drop whose row count is unstated, so a drop can never land alone — but a drop and its declaration land together in one commit and internally agree, and nothing outside the migrations directory moves. This number is the thing outside.
ZERO IS A NUMBER LIKE ANY OTHER, and that is the correction this function carries. The harness used to refuse a chain with no drops outright, reasoning that such a run would assert nothing. That was true of the population it was written for — every caller had drops — and it stopped being true the moment a service squashed its chain into one primary migration: a squashed chain is a STATE, and a state has no history of drops. The refusal then fired on the correct answer. What remains asserted at zero is the ratchet itself: add a drop and the count moves off zero.
filesScanned is named in the finding rather than checked here: "the chain holds no drops" and "nothing was read" are different states, Inventory already refuses the second, and a reader looking at a mismatch needs to know how much was read to act on it.
func Gate ¶
func Gate(ctx context.Context, db Querier, service string, fsys fs.FS, out io.Writer, target Target) error
Gate is the whole live check as one call, for a migration runner to make before it applies anything.
It is deliberately the only shape a runner needs, and it takes no options that could turn it off. Seven binaries wire this line; a check each of them assembled for itself would drift, and the one that drifted would be the one nobody looked at.
target is how far the caller is about to apply, and it is REQUIRED rather than optional for that reason: a runner that stops at a version has to say so in the same call, next to the goose call it mirrors, where the two can be read together. It is not an off switch — Target says why the zero value counts everything and why narrowing it narrows what runs by exactly as much.
The census goes to out on every run, refused or not — what was read is as much of the result as what was found. The returned error is what stops the deploy.
func ManifestAbsenceIsLegitimate ¶
ManifestAbsenceIsLegitimate says whether an unreadable manifest is acceptable.
It is acceptable in exactly one case: the file is not there AND the chain drops nothing, so there is nothing to declare. This is the same rule the repo-wide static gate already applies to a service that has never dropped a table, and stating it in one place is the point — two mechanisms disagreeing about when a manifest is owed would disagree silently.
Every other unreadable manifest stays fatal, including an unparseable one for a chain with no drops: a guard that could not read what it checks against has not checked, and "the file is malformed" must never resolve to "nothing was owed".
func Observe ¶
Observe returns the number of rows in table, having first established that it was in a position to count them.
It returns a number only when all three of these held: the handle answered a ping, the catalogue lists the table, and the count query succeeded. Otherwise it returns ErrNoConnection or ErrTableAbsent and NO number. There is deliberately no path through this function that yields (0, nil) without a successful COUNT.
func PreserveCommand ¶
PreserveCommand is the one place that says HOW a table's rows are saved before a drop destroys them.
Why a command and not a sentence ¶
The refusal that carries it is read at a bad minute: the deploy has stopped, the operator did not expect it, and the only other next step the message offers is destruction. A sentence ("take a backup first") leaves them to compose a query right then, so the executable option and the safe option are not the same option — and the executable one wins. This returns something that can be pasted.
Why psql and not a verb of our own ¶
The rows belong to whoever runs the database, and they must be readable when the service is NOT running — the refusal happens before the chain is applied and long before any process starts. A verb of ours would have to be shipped, versioned and reachable at exactly the moment the installation is half-upgraded; psql is already how a database is administered, and it needs nothing from us.
Why a bare DSN slot and not the runner's environment variable ¶
The runner takes its address from three sources — a flag, an environment variable, and the service configuration — and in a chart deployment it is the third that holds it. Naming the environment variable would produce a command that is EMPTY for most operators while looking configured, which is the same failure this package exists to prevent, moved into the fix. The slot is deliberately nameless and the message says what to put in it.
Why the table name is passed through untouched ¶
It is the SAME string the guard counted — the one Observe resolved through the catalogue before it reported a number. So the operator exports exactly the object that was refused, qualified exactly as the migration wrote it; a name re-derived here could resolve to a different object under a different search_path and export the wrong rows while looking right.
The file lands in the working directory under a name derived from the table, so two tables saved in one sitting do not overwrite each other.
func PreserveSelect ¶
PreserveSelect is the query inside PreserveCommand — the half that a database can execute, without psql's meta-command around it.
It exists so that a probe can put the DOCUMENTED command to a real schema instead of putting a copy of it there. A probe that retyped the query would agree with itself about a table shape the command never names.
Types ¶
type AppliedSet ¶
AppliedSet reports whether a migration version has already run on this database.
It returns an error rather than a bare bool because "I could not tell" must not collapse into either answer. Guessing "applied" would skip every check — the safe-LOOKING result produced by knowing nothing.
func GooseApplied ¶
func GooseApplied(ctx context.Context, db Querier) AppliedSet
GooseApplied reads the applied set from goose's own bookkeeping table.
A version counts as applied when its most recent row says so: goose appends a row per transition, so a rolled-back migration has a later row with is_applied false, and taking any row rather than the last would report a drop as done when it has been undone.
A database with no bookkeeping table at all has applied nothing. That is a LEGITIMATE state with two ordinary causes, and neither is a fault:
- a fresh install, where the chain has not run yet;
- a database that PREDATES this chain — created outside it, which is exactly the situation the "absent"-kind declarations in this tree are addressed to.
It errs in the safe direction: every drop becomes pending, so every table gets counted. On a fresh install they are all absent and nothing is refused; on a pre-chain database whatever is actually there gets counted, which is the point.
THIS IS NOT THE SAME AS FAILING TO REACH THE DATABASE, and the two must never be folded together — they look alike (no answer about any version) and mean opposite things. The difference is structural rather than a judgement call: a pre-chain database ANSWERS the catalogue query with NULL, while an unreachable one makes that query fail, and a failed query is returned as an error, never as "nothing applied". The same distinction Observe draws between an absent table and an unreachable server. It is asserted as a pair, not assumed — see the integration proof that puts both to the same function and requires different outcomes.
type Approval ¶
Approval is one drop an operator has decided to let through even though the table is not empty.
It names a version AND a table, and nothing wider exists: there is deliberately no "skip the guard" switch. A blanket override would be worth exactly as much as the prose paragraphs this package replaced, and would be reached for under precisely the same pressure.
func ParseApprovals ¶
ParseApprovals reads the operator's list: entries "<version>/<table>", separated by commas or whitespace.
A malformed entry is an error and not a skipped one. Reading a typo as "approve nothing" would turn a mistake into a refusal to deploy whose stated reason is somebody else's table — and the operator would go looking at the table.
type Counter ¶
Counter counts the rows of one table, or explains why it could not.
It is a function rather than a handle so that the decision logic below can be exercised without a database while the only counter that reaches production stays Observe — see Counting. The two error values are part of the contract: a caller must be able to tell "there is nothing there" from "I could not look".
type Declaration ¶
type Declaration struct {
Version int64 `json:"version"`
Table string `json:"table"`
Kind Kind `json:"kind"`
// ExpectRows is the exact number of rows the drop destroys when the chain is
// replayed into an empty database. Zero unless a migration seeds the table.
ExpectRows int64 `json:"expect_rows"`
// Note says why destroying ExpectRows rows is acceptable. Required whenever
// ExpectRows is not zero: a number with no reason behind it is the reasoning
// this package replaces, only shorter.
Note string `json:"note,omitempty"`
}
Declaration is one entry in a service's dropguard.json.
It is not a comment. Every field is checked: Kind against what the migration actually does, ExpectRows against the database, and Note against the requirement that destroying rows be a decision somebody made rather than a number that drifted.
type Drop ¶
type Drop struct {
Service string
Version int64
// Table is the name as written, normalised: lower-cased, unquoted, and
// schema-qualified only when the migration qualified it.
Table string
File string
Line int
// RecreatedHere reports that the same Up section CREATEs the table again. Such
// a drop is an idempotency preamble — on a chain that has never run there is
// nothing there to destroy — and it is read from the migration, never claimed.
RecreatedHere bool
}
Drop is one table-destroying statement found in the Up section of a migration.
type DynamicDrop ¶
type DynamicDrop struct {
Service string
Version int64
File string
Line int
// Text is the fragment as written, so the census shows WHY it was not judged
// instead of asserting that it could not be.
Text string
}
DynamicDrop is a DROP TABLE whose subject is ASSEMBLED AT RUN TIME: a format placeholder or a concatenation stands where the identifier would be, so the name exists only once PL/pgSQL builds the string, and no reading of the file can say which table goes.
It is recorded rather than dropped on the floor because the census is the whole point of this package: a drop nobody counted is the outcome it refuses, and one that cannot be counted has to say so out loud rather than be absent from a number.
type Inv ¶
type Inv struct {
Service string
FilesScanned int
Drops []Drop
// DynamicDrops are the drops this inventory COULD NOT READ: their table name is
// computed at run time. They are not Drops — nothing here knows what they
// destroy — and they are not silence either. See [DynamicDrop] and the package
// doc's "What the reader cannot see".
DynamicDrops []DynamicDrop
// contains filtered or unexported fields
}
Inv is the result of reading a service's migration directory, together with the census that says how much was read. "No drops found" and "no files read" are different answers, and a gate that cannot tell them apart asserts nothing.
func Inventory ¶
Inventory reads every *.sql in fsys as a goose migration and returns the drops in their Up sections. Down sections are excluded on purpose: a Down runs only on a rollback, where the table it drops is one the matching Up created.
func (Inv) CreateVersions ¶
CreateVersions lists the migrations that CREATE table, so a message can name its evidence rather than allude to it.
func (Inv) CreatesTable ¶
CreatesTable reports whether any migration in the chain CREATEs table. A drop of a table nothing here creates destroys nothing on any database these migrations produced.
func (Inv) DropVersions ¶
DropVersions returns every version that drops something, ascending and deduped — the order in which a measured run must step through the chain.
func (Inv) SeedVersions ¶
SeedVersions lists the migrations that INSERT into table before version, for a message that names its evidence instead of alluding to it.
func (Inv) SeedsTable ¶
SeedsTable reports whether any migration strictly BEFORE version writes rows into table. Strictly before, because that is the state the drop destroys.
func (Inv) UnreadableDrops ¶
UnreadableDrops renders each drop whose subject is computed as "NNNN file:line — text", so a census can name its evidence instead of alluding to a count.
type Kind ¶
type Kind string
Kind is what a DROP TABLE statement is doing.
const ( // KindRetire — a table that exists is being taken away. Its contents are // destroyed, so they are counted. KindRetire Kind = "retire" // KindRecreate — `DROP TABLE IF EXISTS x` at the head of a migration that // immediately CREATEs x again, so a re-run lands on the same shape. On a chain // that has never run there is nothing there to destroy, and the gate confirms // that by finding the table ABSENT rather than by believing the label. KindRecreate Kind = "recreate" // KindAbsent — the drop has no subject in this chain: no migration here ever // creates the table, so on any database built from these migrations the // statement is a no-op. Such a drop is addressed at databases that predate the // chain, and saying so is a claim about state this repository does not // contain — which is why it is the one kind that always requires a note. // // Both halves are checked, not believed: the parser confirms no CREATE exists // anywhere in the chain, and the measurement confirms the table is absent at // the version before the drop. Add a CREATE for it later and the declaration // stops being true, so the gate turns it back into a retire that owes a count. KindAbsent Kind = "absent" )
type Manifest ¶
type Manifest struct {
Service string `json:"service"`
Drops []Declaration `json:"drops"`
}
Manifest is a service's dropguard.json.
func LoadManifest ¶
LoadManifest reads a dropguard.json.
type PreflightReport ¶
type PreflightReport struct {
Service string
// Target is how far this run applies. It is on the report because the numbers
// below are answers to a question it asked, and a census that hid which
// question it asked would be the same shape of silence this type prevents.
Target Target
// DropsInChain is every Up-section drop the migrations contain.
DropsInChain int
// Pending is how many of them have not run on THIS database yet AND lie within
// reach of this run — the only ones that can still destroy anything here.
Pending int
// Deferred lists drops the target puts OUT OF REACH of this run.
//
// Whether they have already run is deliberately NOT asked — see the loop — so
// this is not a list of pending drops and must not be read as one. It is on
// the record all the same: silence would make "the target narrowed the check"
// indistinguishable from "there was nothing else to check", and the run that
// widens the target is the one that answers for them.
Deferred []string
// Counted is how many pending drops were actually put to the database. An
// observed absence counts: it is an answer, not a failure to get one.
Counted int
// Rows is the live row count per pending drop, keyed "NNNN/table".
Rows map[string]int64
// AbsentAt lists pending drops whose table is not on this database at all.
AbsentAt []string
// Approved lists non-empty drops an operator released by name.
Approved []string
// StaleApprovals lists approvals that matched no pending drop. They release
// nothing, so they are reported rather than obeyed.
StaleApprovals []string
// DynamicDrops is how many DROP TABLE statements the inventory could not read
// because their subject is computed at run time. They are not Pending and not
// Deferred — this run cannot tell whether they destroy anything, or what — and
// the census prints the number so that inability is visible rather than absent.
// See [DynamicDrop].
DynamicDrops []string
Violations []Violation
}
PreflightReport is what a live check has to say for itself.
Counted sits next to Pending for the same reason Measured sits next to DropsInChain in Report: "no refusals" from a run that asked nothing is the failure this package exists to prevent, and it must not read as success.
func Preflight ¶
func Preflight(ctx context.Context, count Counter, inv Inv, applied AppliedSet, approvals []Approval, target Target) PreflightReport
Preflight counts, on the database in front of it, every table that a migration which has NOT YET RUN — and which target puts within reach of this run — is going to drop.
The order is the whole point: this happens before the chain is applied, while the rows still exist and while refusing still costs nothing but a deploy.
What each outcome means, and why none of them is folded into another:
rows > 0 the drop destroys tenant data. REFUSED unless an operator named
this exact drop in advance.
rows == 0 the table is there and empty. Nothing to lose; proceed.
table absent nothing there to destroy. That is an ANSWER read from the
database, not a failure to get one, and it is the ordinary case
on a fresh install where the chain has not built the table yet.
(The replayed guard treats absence as unverified, because there
the table SHOULD exist at version-1. Here it need not.)
no bookkeeping a database with no goose table has applied nothing. LEGITIMATE,
not a fault: it is a fresh install, or a database that PREDATES
this chain — the state the "absent"-kind declarations are aimed
at. Every drop is therefore pending and every table gets counted,
which errs toward more checking, not less. Do not read this as a
failure and do not relax it; see [GooseApplied] for why it cannot
be confused with an unreachable database.
cannot count NOT VERIFIED. An unreachable database is not an empty one, and a
guard that answers "zero" when it never looked reports the safe
answer under exactly the conditions in which it knows nothing.
cannot tell NOT VERIFIED. If the applied set is unreadable we do not know
which drops are still ahead, and assuming "already applied"
would skip every check.
beyond target DEFERRED, and reported as such. A run that stops at 0007 does
not execute the drop in 0011, so counting that table can only
refuse this deploy over rows this deploy leaves alone. Whether
it has already run is not asked and the census does not claim
either way; the run that widens the target answers for it. See
[Target] for why the zero value counts everything and why
narrowing cannot be used as a bypass.
The window it does not close, stated rather than implied: rows can arrive between this count and the drop, because the old pods are still serving while the migrator runs. This makes the answer seconds old instead of never taken; it does not make it atomic, and nothing available here would.
func (PreflightReport) OK ¶
func (r PreflightReport) OK() bool
OK reports that every pending drop was counted and none was refused.
func (PreflightReport) Summary ¶
func (r PreflightReport) Summary() string
Summary is a one-line census: how much was looked at, not only what was found.
func (PreflightReport) WriteCensus ¶
func (r PreflightReport) WriteCensus(w io.Writer)
WriteCensus prints what was read and what was decided, unconditionally.
type Querier ¶
type Querier interface {
PingContext(ctx context.Context) error
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
Querier is the read surface a measurement needs: *sql.DB and *sql.Conn satisfy it, whether the handle points at a test container or at a live database.
type Report ¶
type Report struct {
Service string
// FilesScanned is how many migration files the inventory read to produce this
// report. It is carried so the census can tell "the chain holds no drops" from
// "nothing was read": those are different states, and a verdict that spells
// them the same way turns a legitimate one into an alarm. A consolidated chain
// — one squashed into a single primary migration — legitimately holds zero
// drops while having been read in full.
FilesScanned int
// DropsInChain is how many Up-section drops the migrations contain.
DropsInChain int
// Measured is how many of them this run actually put a question to the
// database about. Anything less than DropsInChain means the run is partial.
Measured int
// Rows is the observed count per drop, keyed "NNNN/table". Absent entries were
// observed absent, which for an idempotency preamble is the expected answer.
Rows map[string]int64
// AbsentAt lists drops whose table was not there to be counted.
AbsentAt []string
// Unmeasured lists drops this run never put to a database at all. It exists so
// that "nothing was found" can be told apart from "nothing was read": a run
// that skipped the measurement must say which drops it left unanswered.
Unmeasured []string
// DynamicDrops is how many DROP TABLE statements the inventory could not read
// because their subject is computed at run time. They are NOT part of
// DropsInChain — nothing here knows what they destroy — so a run can be OK with
// this number non-zero, and the census says the number out loud rather than
// letting it be absent. See [DynamicDrop].
DynamicDrops []string
Violations []Violation
}
Report is what a measured run has to say for itself: what it found, and how much it looked at. The second half is not decoration — "no violations" from a run that measured nothing is the failure this package exists to prevent, so the census is returned alongside the verdict and callers are expected to assert on it.
func MeasureChain ¶
func MeasureChain(ctx context.Context, db Querier, inv Inv, m Manifest, step StepTo) (Report, error)
MeasureChain replays the migrations one drop-version at a time and counts each table at the version immediately before it is destroyed.
The ordering is the point: a drop's contents can only be counted while they still exist, so the chain is walked forward and paused at each version-1. A run that cannot reach some version stops there and says so, rather than reporting the drops it never got to as clean.
func NothingMeasured ¶
NothingMeasured builds the report of a run that never reached a database: every drop in the chain listed as unanswered.
It exists so that an environment without a database produces a LOUD nothing rather than a quiet something. A guard whose only two outcomes are "green" and "green because it did not run" has one outcome.
func (Report) Summary ¶
Summary is a one-line census: the number of things looked at, not just the number of things found.
func (Report) WriteCensus ¶
WriteCensus prints what the run read and what it decided, unconditionally.
It is written whether or not anything was found, and whether or not the run was verbose, because the number that matters most is how many drops were actually put to the database: a report of no violations from a run that measured nothing is the failure this package exists to prevent, and it must not look like success.
type StepTo ¶
StepTo advances a database to exactly the given migration version. Supplied by the caller because this package does not choose anyone's migration runner.
type Target ¶
type Target struct {
// contains filtered or unexported fields
}
Target says how far the run that is about to happen will apply the chain.
A drop in a migration this run will not reach cannot destroy anything in this run, so counting it can only produce a refusal for somebody else's drop. The operator then clears it the one way there is — by naming that drop — and pays a step for a table this deploy never touches.
THE ZERO VALUE COUNTS EVERYTHING, and that is the whole design of this type. A caller who forgets to say how far it goes gets the widest check, never the narrowest: the mistake this type can still cause is an extra refusal, which an approval clears, and never a silent pass, which nothing clears. There is deliberately no "count nothing" — the same reason Approval names a version and a table and no blanket override exists.
It is not a way around the count either, and the reason is structural rather than a promise: the target is the SAME number handed to goose. Narrowing it to duck a refusal narrows what gets applied by exactly as much, so the drop that was refused does not run.
func WholeChain ¶
func WholeChain() Target
WholeChain is every pending drop: the run stops at the head.
It is spelled out at call sites rather than left implicit so that "this run has no target" is a statement somebody made, not a field nobody filled in.
type Violation ¶
type Violation struct {
Kind ViolationKind
Service string
Version int64
Table string
Detail string
}
Violation is one reason a drop is refused.
func Judge ¶
func Judge(drop Drop, decl Declaration, rows int64, obsErr error) []Violation
Judge turns one measurement into a verdict.
obsErr is whatever Observe returned. It is a parameter rather than something the caller handles beforehand precisely so that a failed measurement cannot be dropped on the floor: there is no way to call Judge without saying what happened.
func Reconcile ¶
Reconcile checks the manifest against the migrations without touching a database: that every drop is declared, that every declaration still has a drop behind it, and that each entry's Kind and ExpectRows are consistent with what the migrations do. It is the half that can run anywhere, and it is what stops a new drop from being merged with its number left unstated.
type ViolationKind ¶
type ViolationKind string
ViolationKind names what went wrong, so a caller can tell a measurement that failed from a measurement that came back bad.
const ( // ViolationUndeclared — a drop with no entry. The number was never stated. ViolationUndeclared ViolationKind = "undeclared-drop" // ViolationExpired — an entry with no drop. It has nothing left to describe, // and an exemption that outlives its subject is the next reader's blind spot. ViolationExpired ViolationKind = "expired-declaration" // ViolationDuplicate — two entries for one drop. ViolationDuplicate ViolationKind = "duplicate-declaration" // ViolationKindMismatch — the entry's Kind is not what the migration does. ViolationKindMismatch ViolationKind = "kind-mismatch" // ViolationUngrounded — rows are expected in a table no migration writes to. ViolationUngrounded ViolationKind = "ungrounded-expectation" // ViolationUnjustified — rows are expected with no reason given. ViolationUnjustified ViolationKind = "unjustified-expectation" // ViolationRowCount — the table did not hold what the entry said it held. ViolationRowCount ViolationKind = "row-count-mismatch" // ViolationUnverified — nothing was measured. Never "clean". ViolationUnverified ViolationKind = "not-verified" )
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package dropguardtest runs a service's drop guard against a real database.
|
Package dropguardtest runs a service's drop guard against a real database. |