Documentation
¶
Overview ¶
Package store defines the persistence boundary of Wegweiser.
It is the only place that knows SQL exists (architecture invariant 3). Everything above it works in terms of zone.Zone, zone.Record and journal.Commit, and the boundary is enforced mechanically: depguard refuses an import of database/sql or of a driver anywhere outside this package tree.
Implementations live in subpackages: sqlite today, postgres later. Callers hold the interface, never the implementation, so a backend swap is a wiring change in cmd/weg and nothing else. Where backends genuinely differ, the difference is reported by Capabilities rather than discovered by a type assertion.
The store is the source of truth. The in-memory snapshot the query path serves from is a derived cache that can always be rebuilt from here, and never the other way around (architecture invariant 8).
Index ¶
Constants ¶
const ( // DefaultLimit is the page size used when a filter names none. DefaultLimit = 100 // MaxLimit is the largest page any listing returns. MaxLimit = 1000 )
Page size bounds. A caller that asks for nothing gets DefaultLimit; one that asks for more than MaxLimit gets MaxLimit, because a page size is client input and an unbounded one is a way to ask the server to allocate until it dies.
Variables ¶
var ( // ErrNotFound means the requested object does not exist. It is also what a // lookup of a revoked or expired token returns, so that a caller cannot // tell those apart from an unknown one. ErrNotFound = errors.New("store: not found") // ErrConflict means the write collided with something already stored: a // duplicate resource record within an RRset (RFC 2181 §5), a zone name // already in use, or a serial the journal already holds a commit for. ErrConflict = errors.New("store: conflict") // ErrSchemaTooNew means the database was written by a newer build than this // one. Continuing would let an older binary write rows the newer schema // expects to be shaped differently, so the store refuses to open instead. ErrSchemaTooNew = errors.New("store: schema newer than this build") // ErrClosed means the store has been closed. ErrClosed = errors.New("store: closed") )
The errors every implementation reports, so that no caller ever has to recognise a driver error. An implementation wraps these with what was being looked for; a caller tests with errors.Is.
Functions ¶
This section is empty.
Types ¶
type Capabilities ¶
type Capabilities struct {
Backend Backend
// ConcurrentWriters is false for SQLite, which serializes writers itself.
// It tells the applier whether its per-zone lock orders writes or merely
// sits in front of the database's own ordering.
ConcurrentWriters bool
// ListenNotify is true when the backend can push change notifications
// instead of being polled. False for SQLite.
ListenNotify bool
}
Capabilities reports what a backend can do beyond the common interface, so a caller can adapt without asserting on the concrete type.
type CommitFilter ¶
type CommitFilter struct {
Paging
ZoneID zone.ZoneID
Kinds []journal.Kind
// Actor matches the recorded actor exactly.
Actor string
// Since and Until bound the commit time, Since inclusive and Until
// exclusive.
Since time.Time
Until time.Time
}
CommitFilter selects journal commits. A zero field is not a constraint.
type Cursor ¶
type Cursor string
Cursor marks a position in a listing. Its contents are the implementation's business: a caller passes back what it was given and nothing else.
type Page ¶
type Page[T any] struct { Items []T // NextCursor is empty once the listing is exhausted. NextCursor Cursor }
Page is one slice of a larger result set.
type Paging ¶
Paging is the cursor and page size shared by every listing.
func (Paging) EffectiveLimit ¶
EffectiveLimit returns the page size to use: DefaultLimit for a filter that names none, and never more than MaxLimit.
type Reader ¶
type Reader interface {
// ZoneByID returns one zone.
ZoneByID(ctx context.Context, id zone.ZoneID) (*zone.Zone, error)
// ZoneByName returns the zone whose apex is exactly name. It does not walk
// up looking for an enclosing zone: which zone answers a query is decided
// against the snapshot, never the database (invariant 2).
ZoneByName(ctx context.Context, name zone.Name) (*zone.Zone, error)
// ListZones returns one page of zones in canonical name order.
ListZones(ctx context.Context, f ZoneFilter) (Page[*zone.Zone], error)
// IterZones streams every zone, disabled ones included, in canonical name
// order. It exists for the snapshot rebuild, which wants each zone once and
// has no reason to carry a cursor.
IterZones(ctx context.Context) iter.Seq2[*zone.Zone, error]
// ReverseZoneFor returns the most specific reverse zone covering addr,
// which is where an address record's PTR belongs. Longest prefix wins, so a
// classless RFC 2317 child beats the /24 delegating to it.
ReverseZoneFor(ctx context.Context, addr netip.Addr) (*zone.Zone, error)
// RecordByID returns one record.
RecordByID(ctx context.Context, id zone.RecordID) (*zone.Record, error)
// ListRecords returns one page of records in canonical order.
ListRecords(ctx context.Context, f RecordFilter) (Page[*zone.Record], error)
// IterZoneRecords streams every record of a zone, disabled ones included,
// in canonical order. It must not materialize the zone: a large one holds
// millions of records.
IterZoneRecords(ctx context.Context, id zone.ZoneID) iter.Seq2[*zone.Record, error]
// RecordsByAddress returns every A and AAAA record pointing at addr, across
// all zones. Reverse automation asks before generating a second PTR.
RecordsByAddress(ctx context.Context, addr netip.Addr) ([]*zone.Record, error)
// ManagedBy returns the records generated from the given source record.
ManagedBy(ctx context.Context, id zone.RecordID) ([]*zone.Record, error)
// ManagedByZone streams the records generated from any record of a zone and
// living somewhere else, in canonical order. Deleting a zone takes them
// with it, written out rather than cascaded, because the removals belong to
// the journal of the zone they are in.
ManagedByZone(ctx context.Context, id zone.ZoneID) iter.Seq2[*zone.Record, error]
// CommitByID returns one journal commit with its events. Not named Commit:
// this interface is embedded in [Tx], where that would read as the method
// ending the transaction.
CommitByID(ctx context.Context, id journal.CommitID) (*journal.Commit, error)
// ListCommits returns one page of commit metadata, newest first and without
// events. The events of one commit are fetched by identifier.
ListCommits(ctx context.Context, f CommitFilter) (Page[*journal.Commit], error)
// TokenByHash resolves an API token by the SHA-256 of its secret. Unknown,
// revoked and expired tokens all return [ErrNotFound], so a caller cannot
// tell which of the three it hit.
TokenByHash(ctx context.Context, hash []byte) (*Token, error)
// ListTokens returns every token, including revoked and expired ones.
// Secrets are not stored and cannot be returned.
ListTokens(ctx context.Context) ([]*Token, error)
// Setting returns one JSON-encoded setting value.
Setting(ctx context.Context, key string) ([]byte, error)
}
Reader is the read-only surface of the store, satisfied by both Store and Tx so a helper written against it works inside or outside a transaction.
A lookup that finds nothing returns ErrNotFound, never a nil result. The streaming methods yield each item with a nil error; on failure they yield a nil item with the error and stop, so the error must be tested on every step.
type RecordFilter ¶
type RecordFilter struct {
Paging
ZoneID zone.ZoneID
// Name matches one owner name exactly.
Name zone.Name
// Under matches an owner name at or below this name, which is how the GUI
// expands one branch of the name tree.
Under zone.Name
Types []zone.RRType
// Prefix selects the address records pointing into one network. It is how
// a reverse zone finds the records it should be answering for.
Prefix netip.Prefix
// Search matches anywhere in the owner name or the record data,
// case-insensitively.
Search string
// Managed selects only generated records, or only authored ones.
Managed *bool
}
RecordFilter selects records. A zero field is not a constraint.
type Store ¶
type Store interface {
Reader
// Update runs fn inside one write transaction, committing if fn returns nil
// and rolling back otherwise. Writers are serialized: SQLite by
// construction, Postgres by asking.
//
// The Tx must not outlive fn or be used from another goroutine.
Update(ctx context.Context, fn func(tx Tx) error) error
// View runs fn inside one read transaction, for a caller that needs several
// reads to see the same state.
View(ctx context.Context, fn func(r Reader) error) error
// Migrate brings the schema up to what this build expects. It returns
// [ErrSchemaTooNew] if the database was written by a newer build.
Migrate(ctx context.Context) error
// Ping checks that the backend is reachable. It is what /healthz asks.
Ping(ctx context.Context) error
// Capabilities reports optional backend features.
Capabilities() Capabilities
// Close releases the backend. Transactions still running are rolled back.
Close() error
}
Store is a persistence backend.
type Token ¶
type Token struct {
ID TokenID
Name string
// Prefix is the leading characters of the secret, kept so the UI and the
// audit log can tell two tokens apart without holding either.
Prefix string
// Hash is the SHA-256 of the secret. Not a slow password hash: a token is
// 256 bits from crypto/rand and is not brute-forceable whatever the hash
// costs, so a slow one would only rate-limit our own request path. See
// data model §4.7.
Hash []byte
// Scopes are the permissions granted. The vocabulary belongs to the API,
// which is the only thing that interprets them.
Scopes []string
CreatedAt time.Time
// LastUsedAt is zero for a token that has never authenticated a request.
// It is written advisorily and may lag.
LastUsedAt time.Time
// ExpiresAt is zero for a token that does not expire.
ExpiresAt time.Time
// RevokedAt is zero for a token that is still valid.
RevokedAt time.Time
}
Token is an API credential.
It lives in this package rather than beside the authentication code because the authentication code lives in internal/api, and nothing may import that (architecture invariant 1). The alternative, a package holding one struct, buys nothing.
type Writer ¶
type Writer interface {
// CreateZone stores a new zone. It returns [ErrConflict] if the apex is
// already taken.
CreateZone(ctx context.Context, z *zone.Zone) error
// UpdateZone replaces a zone's settings. It does not touch its records.
UpdateZone(ctx context.Context, z *zone.Zone) error
// DeleteZone removes a zone together with its records and its journal.
DeleteZone(ctx context.Context, id zone.ZoneID) error
// SetZoneSerial advances a zone serial. Separate from UpdateZone because
// the serial belongs to the journal, not to whoever is editing the zone.
SetZoneSerial(ctx context.Context, id zone.ZoneID, serial zone.Serial) error
// InsertRecord stores a new record. It returns [ErrConflict] if an
// identical resource record is already in the same RRset (RFC 2181 §5).
InsertRecord(ctx context.Context, r *zone.Record) error
// UpdateRecord replaces a record in place, keeping its identity so its
// comment, history and generated PTR stay attached.
UpdateRecord(ctx context.Context, r *zone.Record) error
// DeleteRecord removes one record, and with it anything generated from it.
DeleteRecord(ctx context.Context, id zone.RecordID) error
// DeleteRRset removes every record of one owner name, class and type. The
// RRset is the unit DNS operates on (RFC 2181 §5), and enumerating members
// first would race with whoever adds one in between.
DeleteRRset(ctx context.Context, id zone.ZoneID, key zone.RRsetKey) error
// AppendCommit stores a commit and its events. It returns [ErrConflict] if
// the zone already has a commit producing that serial, which is the last
// line of defence for one commit per serial step.
AppendCommit(ctx context.Context, c *journal.Commit) error
// CreateToken stores a new API token.
CreateToken(ctx context.Context, t *Token) error
// RevokeToken marks a token unusable. Tokens are not deleted, so the audit
// log keeps naming the token behind each change.
RevokeToken(ctx context.Context, id TokenID, at time.Time) error
// TouchToken records that a token authenticated a request. It is advisory:
// an implementation may batch or drop these writes, and no caller may
// depend on the value it reads back.
TouchToken(ctx context.Context, id TokenID, at time.Time) error
// PutSetting stores a JSON-encoded setting value, replacing any previous
// one.
PutSetting(ctx context.Context, key string, value []byte) error
}
Writer is the mutating surface, reachable only through Store.Update, so nothing writes outside a transaction. Methods taking a pointer write back the timestamps the store owns.
type ZoneFilter ¶
type ZoneFilter struct {
Paging
// Kind restricts the listing to forward or to reverse zones.
Kind zone.Kind
// Name matches one apex exactly. It is what a client resolves a name a
// person typed into the zone it belongs to, which every command taking a
// zone name has to do before it can do anything else. Search cannot serve
// that: "example.com" also matches "notexample.com" and "example.com.au".
Name zone.Name
// Search matches anywhere in the zone name, case-insensitively. It backs
// the instant filter above the zone list.
Search string
// Disabled selects only disabled or only enabled zones.
Disabled *bool
}
ZoneFilter selects zones. A zero field is not a constraint.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package sqlite implements the Wegweiser store on SQLite.
|
Package sqlite implements the Wegweiser store on SQLite. |
|
Package storetest is the conformance suite every store.Store implementation has to pass.
|
Package storetest is the conformance suite every store.Store implementation has to pass. |