Documentation
¶
Overview ¶
Package preflight runs read-only self-checks at startup and reports what it found in a form an operator can act on without a second round trip.
The problem it addresses is timing. Most deployment mistakes -- a directory the process cannot write to, a network that no longer exists, a socket the user has no permission on -- are perfectly detectable the moment the process starts, and are instead discovered much later, by the first piece of real work that needs them. By then the failure surfaces as whatever that work happens to fail with, at a moment nobody is watching, in a message that says nothing about the setting that caused it.
So a Result carries more than a verdict. Result.Message says what was observed; Result.Hint is a command the reader can paste. A check that reports a problem without saying what to do about it has moved the work rather than done it.
Checks are read-only, and a failing check never stops the program: reporting is the whole job. A startup self-check that refuses to start is a self-check that will be removed the first time it is wrong about an environment its author did not anticipate.
Layout ¶
The package has no dependencies beyond the standard library, and splits into two halves that share one purpose -- neither is finished until it has said what to do about what it found.
Startup: Check, Run and Results are the harness; Named and CheckFunc adapt a function to it; OK, Warn and Fail build the verdicts. The built-in probes -- DirWritable, PathExists, TCPReachable, SocketAccessible, SubdirsPrivate and MissingCommands -- cover the settings that go wrong most often.
Afterwards: Classifier turns a runtime failure into a Diagnosis, so the same question ("what should I do about this?") gets an answer when something breaks later rather than at startup. Wrap and KindOf carry a failure's kind from where it happens to where it is reported.
Getting started ¶
results := preflight.Run(ctx,
preflight.Named("data directory", func(ctx context.Context) preflight.Result {
return preflight.DirWritable("data directory", cfg.DataDir)
}),
preflight.Named("database", func(ctx context.Context) preflight.Result {
return preflight.TCPReachable(ctx, "database", cfg.DBAddr, 3*time.Second)
}),
)
results.Log(log.Printf)
if !results.OK() {
log.Printf("starting anyway; the findings above are worth fixing")
}
Results.Log writes every result, including the passing ones: the record of what the environment looked like at startup is what someone reads first when the same deployment misbehaves three weeks later. Results.OK reports whether anything failed -- warnings do not count, being things to look at rather than reasons to stop -- and what a caller does with that answer is its own decision, because this package never makes it.
Writing a check ¶
A check is any function of a context returning a Result; Named gives it a name and makes it report under that name even when it panics or the preflight deadline elapses first. A Check with a type of its own gets the same treatment by implementing NamedCheck. Implementations must be read-only, because preflight runs on every start, including starts that are already going badly. The one deliberate exception is DirWritable, which creates and removes a randomly named probe file -- the only way to learn whether a directory really accepts writes is to write.
Checks run in the order they are given, sequentially. Running them concurrently would save milliseconds and scramble the one thing a human reads the output for.
Example ¶
The common case: run the checks at startup, log every result, and carry on. A failing check never stops the program -- reporting is the whole job.
package main
import (
"context"
"fmt"
preflight "github.com/soulteary/preflight-kit"
)
func main() {
results := preflight.Run(context.Background(),
preflight.Named("data directory", func(context.Context) preflight.Result {
return preflight.OK("data directory", "/srv/app/data is writable (uid 1001)")
}),
preflight.Named("docker socket", func(context.Context) preflight.Result {
return preflight.Warn("docker socket",
"/var/run/docker.sock is owned by gid 998 and this process is not in that group; access will be denied",
`add the process to gid 998 (docker-compose: group_add: ["998"])`)
}),
)
results.Log(func(format string, args ...any) { fmt.Printf(format+"\n", args...) })
fmt.Println("ok:", results.OK(), "worst:", results.Worst())
}
Output: [preflight ok] data directory: /srv/app/data is writable (uid 1001) [preflight !] docker socket: /var/run/docker.sock is owned by gid 998 and this process is not in that group; access will be denied -> add the process to gid 998 (docker-compose: group_add: ["998"]) ok: true worst: warn
Index ¶
- Constants
- func FileGID(path string) int
- func InGroup(gid int) bool
- func KindOf(err error) string
- func Wrap(kind string, err error) error
- type Advice
- type Check
- type CheckFunc
- type Classifier
- type Diagnosis
- type Error
- type Level
- type NamedCheck
- type Result
- func DirWritable(name, dir string) Result
- func Fail(name, message, hint string) Result
- func MissingCommands(name, where string, missing []string) Result
- func OK(name, message string) Result
- func PathExists(name, path, hint string) Result
- func SocketAccessible(name, path string) Result
- func SubdirsPrivate(name string, dirs []string) Result
- func TCPReachable(ctx context.Context, name, addr string, timeout time.Duration) Result
- func Warn(name, message, hint string) Result
- type Results
- type Rule
Examples ¶
Constants ¶
const DefaultDialTimeout = 3 * time.Second
DefaultDialTimeout bounds a TCPReachable probe that is given none.
const DefaultFallbackKind = "unknown"
DefaultFallbackKind is the Kind assigned when nothing matches.
Variables ¶
This section is empty.
Functions ¶
func InGroup ¶
InGroup reports whether this process belongs to gid, as its primary group or a supplementary one.
Types ¶
type Advice ¶
type Advice struct {
// Suggestion is a sentence: what is likely wrong, in prose.
Suggestion string
// CheckCommand inspects, and changes nothing. Safe to run anywhere.
CheckCommand string
// FixCommand changes something. It may restart services or rewrite
// configuration, and a UI should present it as an action rather than
// as information.
FixCommand string
}
Advice is what to do about a class of failure.
CheckCommand and FixCommand are separate on purpose. Someone diagnosing a problem on a machine they do not own needs to be able to look before they touch, and a single "run this" field forces the author to choose between giving a safe command and giving a useful one. Splitting them means the read-only one can always be offered first.
type Check ¶
Check is one self-check. Implementations must be read-only: preflight runs on every start, including starts that are already going badly.
type Classifier ¶
type Classifier struct {
Rules []Rule
// Fallback is used when nothing matches. A classifier with no fallback
// still returns a Diagnosis, just without advice.
Fallback Advice
// FallbackKind is the Kind given to unmatched errors. Empty means
// "unknown".
FallbackKind string
}
Classifier maps errors to advice.
Kind is matched first, and substrings only as a fallback. That order is deliberate: an error tagged at the point of failure knows what it is, while matching on text is guesswork that goes wrong quietly -- it survives until someone rewords a message or a library is updated, and then silently starts classifying everything as the fallback. Keeping it as the second-choice path means existing call sites keep working while new ones are tagged properly.
func (Classifier) Diagnose ¶
func (c Classifier) Diagnose(err error) Diagnosis
Diagnose classifies err and returns the advice for it.
Example ¶
Diagnose answers the other half of the question: when something fails later, what kind of failure is it and what should be done about it. A kind tagged at the point of failure is matched first; matching on the message text is only the fallback, because that goes wrong quietly when someone rewords a message.
CheckCommand and FixCommand are separate so the read-only one can always be offered first.
package main
import (
"errors"
"fmt"
preflight "github.com/soulteary/preflight-kit"
)
func main() {
classifier := preflight.Classifier{
Rules: []preflight.Rule{{
Kind: "docker-permission",
Substrings: []string{"permission denied while trying to connect"},
Advice: preflight.Advice{
Suggestion: "this process is not in the group that owns the docker socket",
CheckCommand: "stat -c '%g' /var/run/docker.sock",
FixCommand: "usermod -aG docker app && systemctl restart app",
},
}},
Fallback: preflight.Advice{Suggestion: "no advice for this failure yet"},
}
// Tagged where it happened, so the message text is never consulted.
tagged := preflight.Wrap("docker-permission", errors.New("dial unix /var/run/docker.sock: connect: permission denied"))
known := classifier.Diagnose(tagged)
fmt.Println(known.Kind)
fmt.Println(known.CheckCommand)
unknown := classifier.Diagnose(errors.New("no space left on device"))
fmt.Println(unknown.Kind, "-", unknown.Suggestion)
}
Output: docker-permission stat -c '%g' /var/run/docker.sock unknown - no advice for this failure yet
type Diagnosis ¶
type Diagnosis struct {
// Kind classifies the failure, so a UI can branch on it without parsing
// a message.
Kind string
// Error is the underlying error's text, kept verbatim.
Error string
Advice
}
Diagnosis turns a runtime failure into something the reader can act on.
Preflight answers "is this deployment sound" at startup. This answers the other half: when something fails later, what kind of failure is it and what should be done about it. The two share a purpose -- neither is finished until it has said what to do -- and Advice is the shape of that answer.
type Error ¶
Error carries a Kind alongside an error, so a failure can be classified where it happens rather than guessed at later from its text.
type NamedCheck ¶
type NamedCheck interface {
Check
// Name is the check's name, in the operator's vocabulary. An empty name
// is treated as no name at all.
Name() string
}
NamedCheck is a Check that reports under a name of its own.
It exists because two Results are produced by the harness rather than by the check: the one for a check that panicked, and the one for a check the preflight deadline reached first. Neither can take its name from a Result the check never returned, so Run asks the Check itself. A Check that does not implement NamedCheck is reported as "check" in those two cases.
Named is the usual way to get one; implement it directly when a Check has its own type.
type Result ¶
type Result struct {
// Name identifies the check, in the operator's vocabulary rather than the
// code's -- "runners directory", not "checkBasePath".
Name string
// Level is the verdict.
Level Level
// Message says what was observed. It should name the actual path, address
// or value involved: "/srv/runners is not writable by uid 1001" sends the
// reader somewhere, "permission problem" does not.
Message string
// Hint is what to do about it, ideally a command to paste. Empty for a
// passing check.
Hint string
}
Result is one check's finding.
func DirWritable ¶
DirWritable checks that dir exists, is a directory, and that this process can both create and remove a file in it.
Both, not just create. A directory can accept a new file and refuse to let you delete it -- a sticky bit, or a mount that turns read-only under the first write -- and a check that stops at "created it" calls that directory fine. The program then fails later, on the first thing that needs to replace or clean up a file, with no connection back to this setting.
The probe file has a random name. A fixed one collides with a real file of that name, and a self-check that opens and deletes the user's data is worse than no self-check at all.
Example ¶
DirWritable really writes: it creates a randomly named probe file and removes it again. Both halves matter -- a directory that accepts a new file and refuses to let it be deleted is how a read-only remount first shows itself, and a check that stops at "created it" calls that directory fine.
package main
import (
"fmt"
"log"
"os"
"path/filepath"
preflight "github.com/soulteary/preflight-kit"
)
func main() {
dir, err := os.MkdirTemp("", "preflight-example-")
if err != nil {
log.Fatal(err)
}
defer func() { _ = os.RemoveAll(dir) }()
usable := preflight.DirWritable("data directory", dir)
fmt.Println(usable.Level, usable.Failed())
missing := preflight.DirWritable("data directory", filepath.Join(dir, "does-not-exist"))
fmt.Println(missing.Level, missing.Failed())
}
Output: ok false error true
func MissingCommands ¶
MissingCommands turns the result of a "which of these commands exist" probe into a Result. The caller supplies the missing list, because how to ask depends on where the commands have to be -- this host, a container image, a remote machine -- and this package will not guess. On the local PATH that is exec.LookPath; in an image it is a `docker run` away; on another machine it is someone else's problem entirely.
It is named for what it takes rather than what it checks, because it does not check anything: the lookup has already happened by the time it is called.
Missing tools deserve a check of their own because of how they fail: not with "command not found" at a useful moment, but as whatever the thing calling them does when it is absent. A build step that silently downloads a tarball instead of cloning, because git is missing, looks like a success until someone wonders why the working directory has no history.
func PathExists ¶
PathExists checks that a path exists, without saying anything about what can be done with it.
func SocketAccessible ¶
SocketAccessible checks that a unix socket exists and that this process is in the group that owns it.
The group membership is the part worth checking. The socket being present is easy to see and easy to get right; being in its group is neither, and it is the half that produces "permission denied" from inside a container long after everyone has concluded the mount is correct.
func SubdirsPrivate ¶
SubdirsPrivate reports the directories under a parent whose permissions let other users in.
Separate from DirWritable because it answers a different question -- not "can I use this" but "can anyone else read what I put here". It reports and does not change anything: tightening permissions can break a deployment whose uids do not line up the way the checker assumes, so the decision belongs to whoever can see the whole picture.
func TCPReachable ¶
TCPReachable checks that something is listening on addr.
It exists because "the address is configured" and "the address answers" get conflated, and only the second one means anything. A check that reports the configured value back to the reader, in green, has told them nothing: a dependency that is configured but down looks exactly the same.
Always bounded. An unreachable address that blackholes packets hangs until the TCP stack gives up, which is far longer than anyone will wait at startup for a check that is meant to be reassuring.
Example ¶
TCPReachable answers "does the address answer", which is the only half that means anything: a dependency that is configured but down looks exactly like one that is configured and up. An unreachable dependency is a warning, not an error -- something to look at, not a reason to refuse to start.
package main
import (
"context"
"fmt"
"log"
"net"
"time"
preflight "github.com/soulteary/preflight-kit"
)
func main() {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatal(err)
}
addr := listener.Addr().String()
up := preflight.TCPReachable(context.Background(), "database", addr, time.Second)
fmt.Println(up.Level, up.Failed())
_ = listener.Close()
down := preflight.TCPReachable(context.Background(), "database", addr, time.Second)
fmt.Println(down.Level, down.Failed())
}
Output: ok false warn false
type Results ¶
type Results []Result
Results is an ordered list of findings.
func Run ¶
Run executes checks in order and collects their results.
In order, and sequentially. Checks are cheap, and the order they are written in is usually the order that reads best -- "is the directory there" before "is the image there" before "can jobs reach docker". Running them concurrently would save milliseconds and scramble the one thing a human reads the output for.
A panicking check becomes an error result rather than taking the program down with it. A self-check is not worth a crash at startup.
Example (PanickingCheck) ¶
A panicking check becomes an error result, under its own name, rather than taking the program down with it. A self-check is not worth a crash at startup.
package main
import (
"context"
"fmt"
preflight "github.com/soulteary/preflight-kit"
)
func main() {
results := preflight.Run(context.Background(),
preflight.Named("image present", func(context.Context) preflight.Result {
panic("read of a nil map")
}),
)
fmt.Println(results[0].Name, results[0].Level)
fmt.Println(results[0].Message)
}
Output: image present error the check itself panicked: read of a nil map
func (Results) Log ¶
Log writes every result through logf, one line each.
Every result, including the passing ones. The value of a startup self-check is as much in "docker daemon 27.0.3, network app-net exists, /srv/data writable by uid 1001" as in any single failure: it is the record of what the environment looked like, and it is what someone reads first when the same deployment misbehaves three weeks later.
func (Results) OK ¶
OK reports whether nothing failed. Warnings do not count: they are things to look at, not reasons to stop.
func (Results) SortedByLevel ¶
SortedByLevel returns the results with errors first, then warnings, then passes, preserving the original order within each group.
For a UI that shows the worst first. Log output should stay in check order.
Example ¶
SortedByLevel is for a UI that shows the worst first. Log output stays in check order, because the order the checks are written in is usually the order that reads best.
package main
import (
"fmt"
preflight "github.com/soulteary/preflight-kit"
)
func main() {
results := preflight.Results{
preflight.OK("data directory", "/srv/app/data is writable"),
preflight.Warn("docker socket", "this process is not in the owning group", `group_add: ["998"]`),
preflight.OK("network", "app-net exists"),
preflight.Fail("image", "app:v2 is not present locally", "docker pull app:v2"),
}
for _, r := range results.SortedByLevel() {
fmt.Println(r.Level, r.Name)
}
}
Output: error image warn docker socket ok data directory ok network
type Rule ¶
type Rule struct {
// Kind is the classification this rule assigns.
Kind string
// Substrings matches an error whose text contains any of these,
// lower-cased. Used only when the error carries no Kind of its own.
Substrings []string
// Advice is what to tell the reader.
Advice
}
Rule matches a class of failure and says what to do about it.