preflight

package module
v1.0.0 Latest Latest
Warning

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

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

README

preflight-kit

CI Go Reference Go Report Card License

Startup self-checks that say what to do, not just what is wrong. Zero dependencies.

Docs: English · 中文

The problem 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.

They 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.

results := preflight.Run(ctx,
    preflight.Named("data directory", func(ctx context.Context) preflight.Result {
        return preflight.DirWritable("data directory", cfg.DataDir)
    }),
    preflight.Named("docker socket", func(ctx context.Context) preflight.Result {
        return preflight.SocketAccessible("docker socket", "/var/run/docker.sock")
    }),
)
results.Log(log.Printf)
[preflight ok] data directory: /srv/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"])

Install

go get github.com/soulteary/preflight-kit

The package is named preflight, not preflight-kit, so spell the import name out:

import preflight "github.com/soulteary/preflight-kit"

A finding without a fix has moved the work, not done it

Result carries a Hint as well as a Message, and the message is expected to name the actual path, address or value involved.

/srv/runners is not writable by uid 1001 sends the reader somewhere. permission problem does not.

Three rules the package holds to

Checks are read-only. Preflight runs on every start, including starts that are already going badly. A self-check with side effects is one more thing that can make a bad situation worse.

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's wrong about an environment its author didn't anticipate.

Passing results are logged too. docker daemon 27.0.3, network app-net exists, /srv/data writable by uid 1001 is the record of what the environment looked like — and it's what someone reads first when the same deployment misbehaves three weeks later.

Built-in probes

Probe What it actually checks
DirWritable Create and remove a file. See below
SocketAccessible The socket exists and this process is in its group
TCPReachable Something answers — always bounded by a timeout
SubdirsPrivate Which directories let other users in. Reports; never changes
PathExists, MissingCommands, FileGID, InGroup
Why DirWritable also deletes

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. 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, because 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.

Why TCPReachable is 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 meant to be reassuring. A zero timeout becomes DefaultDialTimeout, not "forever".

Why SubdirsPrivate only reports

Tightening permissions can break a deployment whose uids don't line up the way the checker assumes. The decision belongs to whoever can see the whole picture; the check's job is to make sure they know.

Diagnosing failures that happen later

Preflight answers "is this deployment sound" at startup. Classifier answers the other half: when something fails at runtime, what kind of failure is it and what should be done about it.

var classifier = preflight.Classifier{
    Rules: []preflight.Rule{{
        Kind:       "docker-access",
        Substrings: []string{"permission denied", "cannot connect to the docker daemon"},
        Advice: preflight.Advice{
            Suggestion:   "check the socket mount and group membership",
            CheckCommand: "ls -l /var/run/docker.sock && id",
            FixCommand:   "usermod -aG docker $USER && systemctl restart docker",
        },
    }},
    Fallback: preflight.Advice{CheckCommand: "docker compose ps"},
}

d := classifier.Diagnose(err)   // Kind, Error, Suggestion, CheckCommand, FixCommand

CheckCommand and FixCommand are separate on purpose. Someone diagnosing a problem on a machine they don't own needs 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 — and a UI can present the other as an action rather than as information.

Tag errors where they happen; match text only as a fallback.

return preflight.Wrap("agent-connect", err)

An error tagged at the point of failure knows what it is. Matching on message 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. Diagnose checks the tag first and falls back to substrings, so existing call sites keep working while new ones are tagged properly.

Results

results.OK()             // nothing failed; warnings don't count
results.Worst()          // LevelOK | LevelWarn | LevelError
results.Problems()       // warnings and errors, in check order
results.Log(log.Printf)  // every result, one line each
results.SortedByLevel()  // worst first, for a UI

Run executes checks in order, sequentially. Checks are cheap, and the order they're 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.

Requirements

  • Go 1.27+ (go.mod declares go 1.27.0). The kits track the current Go release together, so this is a deliberate floor rather than the lowest the code could run on. Note that a library's go directive is a hard minimum for everyone who imports it: go get will raise your own go.mod to match.
  • No dependencies. The standard library is the whole of it, tests included.
  • Unix only. FileGID reads POSIX ownership through syscall.Stat_t, so the package does not build on Windows. macOS and Linux both work; the behaviour the probes describe — uids, gids, socket ownership — is Linux's.

Test Coverage

go test ./... -v

# With coverage — what CI runs
go test -race -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -html=coverage.out -o coverage.html
go tool cover -func=coverage.out

Statement coverage is 95.2%. The test job runs on Linux and macOS, on the Go version go.mod declares; the Linux job uploads the browsable HTML report as a build artifact. No coverage service is involved.

The runnable examples in example_test.go are part of the suite. They are an external test package (package preflight_test), so they compile only against the exported API — which keeps that API honest about being usable from outside — and go test checks their printed output, so they cannot drift from what the docs claim.

Changelog

See CHANGELOG.md.

Security

Findings name real paths, uids and addresses on purpose, which makes the output a log for operators rather than something to publish. Hints are commands, and nothing here runs them. SECURITY.md explains what both mean for a caller, and how to report a vulnerability — please do not open a public issue for one.

Contributing

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add some amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

License

Apache 2.0 — see LICENSE.

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

Examples

Constants

View Source
const DefaultDialTimeout = 3 * time.Second

DefaultDialTimeout bounds a TCPReachable probe that is given none.

View Source
const DefaultFallbackKind = "unknown"

DefaultFallbackKind is the Kind assigned when nothing matches.

Variables

This section is empty.

Functions

func FileGID

func FileGID(path string) int

FileGID returns the group that owns path, or -1 when that cannot be determined.

func InGroup

func InGroup(gid int) bool

InGroup reports whether this process belongs to gid, as its primary group or a supplementary one.

func KindOf

func KindOf(err error) string

KindOf returns the kind carried by err, or "" when it carries none.

func Wrap

func Wrap(kind string, err error) error

Wrap tags err with a kind. It returns nil when err is nil, so it can be applied to a result without a preceding check.

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

type Check interface {
	Run(ctx context.Context) Result
}

Check is one self-check. Implementations must be read-only: preflight runs on every start, including starts that are already going badly.

type CheckFunc

type CheckFunc func(ctx context.Context) Result

CheckFunc adapts a function to Check.

func (CheckFunc) Run

func (f CheckFunc) Run(ctx context.Context) Result

Run calls f.

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

type Error struct {
	Kind string
	Err  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.

func (*Error) Error

func (e *Error) Error() string

Error implements error.

func (*Error) Unwrap

func (e *Error) Unwrap() error

Unwrap returns the wrapped error.

type Level

type Level string

Level is how much a result matters.

const (
	// LevelOK means the check passed.
	LevelOK Level = "ok"
	// LevelWarn means it will probably cause trouble later, but the program
	// can start.
	LevelWarn Level = "warn"
	// LevelError means that, as configured, this will not work.
	LevelError Level = "error"
)

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.

func Named

func Named(name string, f func(ctx context.Context) Result) NamedCheck

Named wraps a function as a Check that reports under the given name, including when it panics or the context is cancelled.

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

func DirWritable(name, dir string) Result

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 Fail

func Fail(name, message, hint string) Result

Fail builds a result for something that will not work as configured.

func MissingCommands

func MissingCommands(name, where string, missing []string) Result

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 OK

func OK(name, message string) Result

OK builds a passing result.

func PathExists

func PathExists(name, path, hint string) Result

PathExists checks that a path exists, without saying anything about what can be done with it.

func SocketAccessible

func SocketAccessible(name, path string) Result

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

func SubdirsPrivate(name string, dirs []string) Result

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

func TCPReachable(ctx context.Context, name, addr string, timeout time.Duration) Result

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

func Warn

func Warn(name, message, hint string) Result

Warn builds a result for something that will probably cause trouble later.

func (Result) Failed

func (r Result) Failed() bool

Failed reports whether the result is an error.

func (Result) String

func (r Result) String() string

String renders a result as one line, with the hint appended when there is one to give.

type Results

type Results []Result

Results is an ordered list of findings.

func Run

func Run(ctx context.Context, checks ...Check) Results

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

func (rs Results) Log(logf func(format string, args ...any))

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

func (rs Results) OK() bool

OK reports whether nothing failed. Warnings do not count: they are things to look at, not reasons to stop.

func (Results) Problems

func (rs Results) Problems() Results

Problems returns the warnings and errors, keeping their original order.

func (Results) SortedByLevel

func (rs Results) SortedByLevel() Results

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

func (Results) String

func (rs Results) String() string

String renders all results, one per line.

func (Results) Worst

func (rs Results) Worst() Level

Worst returns the most severe level present, or LevelOK when there is nothing to report.

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.

Jump to

Keyboard shortcuts

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