Documentation
¶
Overview ¶
Package bedrock models an application as an OS process.
The boundary ¶
exec hands a process its arguments, its environment, its standard streams and the delivery of its signals, and takes back an exit status. bedrock hands an App the same things as one Process value, together with a root context, and takes back an ExitStatus:
Run(ctx context.Context, p Process) ExitStatus
That call is the boundary. An App never reads os.Args, os.Getenv, os.Stdin or the other standard streams, never calls signal.Notify and never calls os.Exit. Everything it learns from its process comes through p, and the only thing it does to its process is return a status. So an App runs whole inside a test, on a hand-built Process.
The one-line main ¶
Main is the only place bedrock touches the real process. It runs an App on OSProcess with context.Background() and exits with the code of the status it returns:
func main() { bedrock.Main(app) }
Main exits with the status code and never prints the cause.
Testing ¶
A test builds a Process from its own values, such as a StaticEnvironment and in-memory streams, runs the App with t.Context() as its root context, and asserts on the returned status and on what the App wrote. Such a test never changes the environment of the test binary, so it can run in parallel.
The cause of an ExitStatus exists so that a negative test can assert why an App failed, for example with errors.Is. Nothing reads it in production. Anything a user should see, the App writes to p.Stderr.
Parsing flags ¶
An App parses its flags from p.Args with a flag.FlagSet of its own, using flag.ContinueOnError and p.Stderr as the FlagSet's output. flag itself prints a parse error followed by the usage to that output, and Main prints nothing, so the user sees each exactly once. The cause of the status carries the same error for tests:
fs := flag.NewFlagSet(p.Args[0], flag.ContinueOnError)
fs.SetOutput(p.Stderr) // flag reports the parse error and usage to the user
n := fs.Int("n", 1, "how many times to greet")
switch err := fs.Parse(p.Args[1:]); {
case errors.Is(err, flag.ErrHelp):
return bedrock.Exit(0)
case err != nil:
return bedrock.ExitWithCause(2, err) // cause is for tests; flag already told the user
}
-h and -help print the usage and exit with status 0. The flags example of App runs this idiom whole.
Beyond the boundary ¶
bedrock owns only what exec hands a process. The network, the filesystem, the clock and randomness belong to the App or to the framework built on bedrock, which passes them in explicitly. In tests, time comes from testing/synctest, and randomness from testing/cryptotest or a seeded source.
For the network and the filesystem, the nested host module, github.com/z5labs/bedrock/host, gives every framework the same contract and the same two implementations, and bedrock itself never passes a Host.
Child processes ¶
An Environment offers lookup only, so it cannot be listed the way os.Environ lists the real environment. An App that starts a child process with os/exec must build the child's exec.Cmd.Env from the keys it knows, reading each through Environment.Value. It must never leave Env nil, because a nil Env inherits the environment of the real process and so reaches around the boundary.
Panics ¶
A panic is an assertion that a programming error happened. bedrock never recovers a panic, so a panic in an App crashes the process with exit status 2.
Ambient leaks ¶
Some of the standard library and of common dependencies read the real process behind an App's back. Avoid them, or configure them from p:
- flag.CommandLine and flag.ExitOnError read os.Args and exit the process. Parse p.Args[1:] with a FlagSet of your own instead; see Parsing flags above.
- An exec.Cmd with a nil Env inherits the real environment; see Child processes above.
- http.ProxyFromEnvironment, the Proxy of http.DefaultTransport, reads the proxy variables of the real environment.
- OpenTelemetry's resource.Default reads the real environment, and its global providers are process-wide state.
- slog.Default writes to the real standard error unless it is replaced, and replacing it is process-wide state.
- os.TempDir, os.UserHomeDir, os.UserConfigDir and os.UserCacheDir read the real environment. Compute them from p.Environment instead.
Engineering rules ¶
bedrock's engineering rules are inspired by NASA's Power of 10 and TigerBeetle's TigerStyle. Neither is a literal standard here: each rule is applied, adapted to Go or rejected, and every rule that is applied is enforced by a tool in CI rather than by review alone.
The boundary itself is applied. golangci-lint's forbidigo fails any use of os.Args, os.Stdin, os.Stdout, os.Stderr, os.Getenv, os.LookupEnv, os.Environ, os.Exit, signal.Notify, signal.NotifyContext, flag.Parse or flag.CommandLine across every module of the repository, tests and examples included, except in the OS edge, its tests, and the test that checks the fake signal delivery of bedrocktest against the real one.
From the Power of 10:
- Rule 1, simple control flow, is adapted: bedrock uses no goto and no recursion, which review keeps true.
- Rule 2, fixed loop bounds, is adapted: every loop ranges over a finite value, and every wait ends when a context is done rather than after a fixed count.
- Rule 3, no allocation after initialization, is adapted. Go allocates and collects garbage, so bedrock allocates when a value is built rather than each time it is used: NewStaticEnvironment copies its map once and a successful Value only reads it.
- Rule 4, short functions, is adapted: functions are kept short, but table-driven tests are exempt, so no tool enforces a line limit.
- Rule 5, assertion density, is adapted: a panic asserts a programming error and is never recovered, and Exit asserts that its code is in range, but assertions are not counted per function.
- Rule 6, smallest scope, is adapted: bedrock has no package-level state, and review keeps every other value at the smallest scope.
- Rule 7, check every result, is applied. errcheck runs on test code as well as library code, so no error is discarded silently, and ExitStatus.Cause lets a test check why every failure path failed, not only that it failed. Checking parameters is adapted; see Preconditions below.
- Rule 8, limited use of the preprocessor, is adapted: Go has none, and build constraints only select a platform.
- Rule 9, no function pointers, is rejected. AppFunc and the func fields of Process are function values, and small functions that compose are how bedrock stays small.
- Rule 10, zero warnings, is applied: go vet and golangci-lint run on every pull request, and any finding fails it.
From TigerStyle:
- Assertions are adapted, as for rule 5: panics assert programming errors, and preconditions are documented rather than checked.
- Putting a limit on everything is adapted: an exit code is limited to 0..255 and Exit checks it, and waits are bounded by contexts.
- Static memory allocation is adapted, as for rule 3.
- Zero dependencies is adapted: the library code of this module imports only the standard library, the library code of the nested fit module imports only the standard library and OpenTelemetry's baggage package, the library code of the nested circuit and retry modules imports only the standard library, the library code of the nested host module imports only the standard library and the host module's own packages, and tests alone may use third-party modules. golangci-lint's depguard enforces all five.
- Exhaustive testing is applied as statement coverage: every non-main package of every module is at 100.0%, and CI fails a pull request that leaves any of them below it.
- Deterministic simulation testing is not claimed; see Testing claims below.
Preconditions ¶
The preconditions of a Process are documented rather than checked at runtime: every field is required, and code that receives a Process uses any field without a nil check. The two places that build a Process, OSProcess and bedrocktest, are guarded instead by tests that use reflection to range over every field, so a field added to Process fails them until it is set.
Testing claims ¶
bedrock claims hermetic, black-box testing with deterministic time: an App runs whole on a fake Process, the test sees only the status it returns and what it writes, and testing/synctest makes its clock deterministic. It does not claim deterministic simulation testing. The Go scheduler, the network and the filesystem are not simulated, so a run cannot be replayed from a seed.
Example (Main) ¶
The main function of a program built on bedrock is one line. This example has no Output comment, so it is compiled but never run: running it would exit the test binary.
package main
import (
"context"
"fmt"
"os"
"github.com/z5labs/bedrock"
)
func main() {
app := bedrock.AppFunc(func(ctx context.Context, p bedrock.Process) bedrock.ExitStatus {
ctx, stop := p.NotifyContext(ctx, os.Interrupt)
defer stop()
if _, err := fmt.Fprintln(p.Stdout, "hello, world"); err != nil {
return bedrock.ExitWithCause(1, err)
}
<-ctx.Done()
return bedrock.ExitWithCause(0, context.Cause(ctx))
})
// In package main:
//
// func main() { bedrock.Main(app) }
bedrock.Main(app)
}
Output:
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Main ¶ added in v0.22.0
func Main(app App)
Main runs app on the current OS process and exits the process with the code of the ExitStatus it returns. It never returns.
Main is a shim for the signature Go's own main function does not have:
func main() { bedrock.Main(app) }
It is exactly os.Exit(app.Run(context.Background(), OSProcess()).Code()). Main ignores the status's ExitStatus.Cause and prints nothing, because the cause exists for tests; an App reports anything a user should see on Process.Stderr. A panic in app is not recovered, so it crashes the process with exit status 2.
Types ¶
type App ¶
type App interface {
// Run runs the App in the given [Process] and returns the status the
// process exits with.
//
// ctx is the root context of the run: [Main] passes
// context.Background() and a test passes t.Context(). An App that
// listens for signals derives its context from ctx through
// [Process.NotifyContext], so cancelling ctx reaches it too.
//
// The code of the returned ExitStatus is the exit status of the
// process, with 0..125 recommended, and the zero ExitStatus means
// success. Its cause is for tests and other callers of Run: [Main]
// ignores it, so an App reports anything a user should see on
// [Process.Stderr].
Run(ctx context.Context, p Process) ExitStatus
}
App is an application that runs as an OS process.
Example ¶
An App runs whole inside a test. bedrocktest hands it a hermetic Process, so the test sets its arguments and environment, reads what it wrote, and asserts on the exit code it returned. Nothing of the real process is read or changed.
package main
import (
"context"
"errors"
"fmt"
"testing"
"github.com/z5labs/bedrock"
"github.com/z5labs/bedrock/bedrocktest"
)
// exampleTB stands in for the *testing.T an Example does not have. In a
// test, pass t to bedrocktest.NewProcess or bedrocktest.Start instead.
type exampleTB struct {
testing.TB
ctx context.Context
cancel context.CancelFunc
cleanups []func()
}
// Context returns a context that runCleanups cancels before it runs the
// cleanups, as testing does with t.Context.
func (tb *exampleTB) Context() context.Context {
if tb.ctx == nil {
tb.ctx, tb.cancel = context.WithCancel(context.Background())
}
return tb.ctx
}
func (*exampleTB) Helper() {}
func (*exampleTB) Errorf(format string, args ...any) {
fmt.Printf("test failed: "+format+"\n", args...)
}
func (tb *exampleTB) Cleanup(f func()) {
tb.cleanups = append(tb.cleanups, f)
}
func (tb *exampleTB) runCleanups() {
if tb.cancel != nil {
tb.cancel()
}
for i := len(tb.cleanups) - 1; i >= 0; i-- {
tb.cleanups[i]()
}
}
func main() {
app := bedrock.AppFunc(func(ctx context.Context, p bedrock.Process) bedrock.ExitStatus {
name, err := p.Environment.Value("NAME")
if err != nil {
if _, werr := fmt.Fprintln(p.Stderr, err); werr != nil {
return bedrock.ExitWithCause(1, errors.Join(err, werr))
}
return bedrock.ExitWithCause(1, err)
}
if _, err := fmt.Fprintf(p.Stdout, "hello, %s\n", name); err != nil {
return bedrock.ExitWithCause(1, err)
}
return bedrock.Exit(0)
})
t := new(exampleTB)
defer t.runCleanups()
fp := bedrocktest.NewProcess(t, bedrocktest.WithEnv(map[string]string{"NAME": "gopher"}))
status := app.Run(context.Background(), fp.Process())
fmt.Print(fp.Stdout())
fmt.Println("exit code:", status.Code())
}
Output: hello, gopher exit code: 0
Example (Flags) ¶
An App parses its flags from p.Args with flag.ContinueOnError and p.Stderr as the FlagSet's output. flag then prints a parse error and the usage to the user exactly once, Main prints nothing more, and the cause carries the same error for tests. -h is a successful exit.
package main
import (
"context"
"errors"
"flag"
"fmt"
"strings"
"testing"
"github.com/z5labs/bedrock"
"github.com/z5labs/bedrock/bedrocktest"
)
// exampleTB stands in for the *testing.T an Example does not have. In a
// test, pass t to bedrocktest.NewProcess or bedrocktest.Start instead.
type exampleTB struct {
testing.TB
ctx context.Context
cancel context.CancelFunc
cleanups []func()
}
// Context returns a context that runCleanups cancels before it runs the
// cleanups, as testing does with t.Context.
func (tb *exampleTB) Context() context.Context {
if tb.ctx == nil {
tb.ctx, tb.cancel = context.WithCancel(context.Background())
}
return tb.ctx
}
func (*exampleTB) Helper() {}
func (*exampleTB) Errorf(format string, args ...any) {
fmt.Printf("test failed: "+format+"\n", args...)
}
func (tb *exampleTB) Cleanup(f func()) {
tb.cleanups = append(tb.cleanups, f)
}
func (tb *exampleTB) runCleanups() {
if tb.cancel != nil {
tb.cancel()
}
for i := len(tb.cleanups) - 1; i >= 0; i-- {
tb.cleanups[i]()
}
}
// greet parses its flags with a FlagSet of its own, from p.Args, and
// reports parse errors through flag itself.
func greet(ctx context.Context, p bedrock.Process) bedrock.ExitStatus {
fs := flag.NewFlagSet(p.Args[0], flag.ContinueOnError)
fs.SetOutput(p.Stderr)
n := fs.Int("n", 1, "how many times to greet")
switch err := fs.Parse(p.Args[1:]); {
case errors.Is(err, flag.ErrHelp):
return bedrock.Exit(0)
case err != nil:
return bedrock.ExitWithCause(2, err)
}
for range *n {
if _, err := fmt.Fprintln(p.Stdout, "hello"); err != nil {
return bedrock.ExitWithCause(1, err)
}
}
return bedrock.Exit(0)
}
func main() {
app := bedrock.AppFunc(greet)
usage := "Usage of " + bedrocktest.ProgramName + ":"
for _, args := range [][]string{{"-n=2"}, {"-h"}, {"-bogus"}, {"-n=abc"}} {
t := new(exampleTB)
fp := bedrocktest.NewProcess(t, bedrocktest.WithArgs(args...))
status := app.Run(context.Background(), fp.Process())
t.runCleanups()
stderr := fp.Stderr()
fmt.Printf("%s: exit code %d\n", args[0], status.Code())
fmt.Printf(" stdout: %q\n", fp.Stdout())
fmt.Printf(" usage printed %d time(s)\n", strings.Count(stderr, usage))
fmt.Printf(" cause: %v\n", status.Cause())
if cause := status.Cause(); cause != nil {
fmt.Printf(" cause printed %d time(s)\n", strings.Count(stderr, cause.Error()))
}
}
}
Output: -n=2: exit code 0 stdout: "hello\nhello\n" usage printed 0 time(s) cause: <nil> -h: exit code 0 stdout: "" usage printed 1 time(s) cause: <nil> -bogus: exit code 2 stdout: "" usage printed 1 time(s) cause: flag provided but not defined: -bogus cause printed 1 time(s) -n=abc: exit code 2 stdout: "" usage printed 1 time(s) cause: invalid value "abc" for flag -n: parse error cause printed 1 time(s)
Example (GracefulShutdown) ¶
An App shuts down gracefully by deriving its context from Process.NotifyContext and returning once a signal cancels it. Returning ExitWithCause(0, context.Cause(ctx)) exits successfully while still telling a test which signal stopped the App.
package main
import (
"context"
"errors"
"fmt"
"syscall"
"testing"
"github.com/z5labs/bedrock"
"github.com/z5labs/bedrock/bedrocktest"
)
// exampleTB stands in for the *testing.T an Example does not have. In a
// test, pass t to bedrocktest.NewProcess or bedrocktest.Start instead.
type exampleTB struct {
testing.TB
ctx context.Context
cancel context.CancelFunc
cleanups []func()
}
// Context returns a context that runCleanups cancels before it runs the
// cleanups, as testing does with t.Context.
func (tb *exampleTB) Context() context.Context {
if tb.ctx == nil {
tb.ctx, tb.cancel = context.WithCancel(context.Background())
}
return tb.ctx
}
func (*exampleTB) Helper() {}
func (*exampleTB) Errorf(format string, args ...any) {
fmt.Printf("test failed: "+format+"\n", args...)
}
func (tb *exampleTB) Cleanup(f func()) {
tb.cleanups = append(tb.cleanups, f)
}
func (tb *exampleTB) runCleanups() {
if tb.cancel != nil {
tb.cancel()
}
for i := len(tb.cleanups) - 1; i >= 0; i-- {
tb.cleanups[i]()
}
}
func main() {
app := bedrock.AppFunc(func(ctx context.Context, p bedrock.Process) bedrock.ExitStatus {
ctx, stop := p.NotifyContext(ctx, syscall.SIGTERM)
defer stop()
if _, err := fmt.Fprintln(p.Stdout, "serving"); err != nil {
return bedrock.ExitWithCause(1, err)
}
<-ctx.Done()
if _, err := fmt.Fprintln(p.Stdout, "shutting down"); err != nil {
return bedrock.ExitWithCause(1, err)
}
return bedrock.ExitWithCause(0, context.Cause(ctx))
})
t := new(exampleTB)
defer t.runCleanups()
fp, statusCh := bedrocktest.Start(t, app)
// Wait until the App listens for SIGTERM, then send a fake one.
<-fp.Notified(syscall.SIGTERM)
fp.Signal(syscall.SIGTERM)
status := <-statusCh
fmt.Print(fp.Stdout())
fmt.Println("exit code:", status.Code())
fmt.Println("cause:", status.Cause())
fmt.Println("cancelled:", errors.Is(status.Cause(), context.Canceled))
}
Output: serving shutting down exit code: 0 cause: terminated signal received cancelled: true
type AppFunc ¶ added in v0.22.0
type AppFunc func(ctx context.Context, p Process) ExitStatus
AppFunc is a function that implements App.
type Environment ¶ added in v0.22.0
type Environment interface {
// Value returns the value of the named variable, or a
// [ValueNotFoundError] naming it when it is not set.
Value(name string) (string, error)
}
Environment provides the environment variables of a process.
It is the only environment type in bedrock: OSEnvironment reads the environment of the current OS process and StaticEnvironment holds a fixed set of values for tests. An Environment offers lookup only, so it cannot be listed; see the package documentation for what that means for child processes.
type ExitStatus ¶ added in v0.22.0
type ExitStatus struct {
// contains filtered or unexported fields
}
ExitStatus is the status an App exits with.
Its code is the exit status of the OS process, so it must lie in 0..255. The codes 0..125 are recommended, since a shell reports the higher ones for its own failures and for processes killed by a signal. The zero ExitStatus means success: its code is 0 and it has no cause.
A non-zero ExitStatus can only be built with Exit or ExitWithCause, both of which panic on a code outside 0..255.
func Exit ¶ added in v0.22.0
func Exit(code int) ExitStatus
Exit returns an ExitStatus with the given code and no cause.
The code is the exit status of the OS process. It must lie in 0..255, and 0..125 is recommended. Exit panics on a code outside 0..255, naming the code, so an invalid code is caught at the return statement that produced it instead of being silently truncated to 8 bits by the OS.
func ExitWithCause ¶ added in v0.22.0
func ExitWithCause(code int, cause error) ExitStatus
ExitWithCause returns an ExitStatus with the given code and the error that caused the App to exit.
The code has the same range as for Exit, and ExitWithCause panics on a code outside 0..255. The panic value is an error naming the code that wraps cause when it is non-nil.
Any code may carry a cause, including 0: a graceful shutdown can return ExitWithCause(0, context.Cause(ctx)) so that a test can assert which signal stopped the App. The cause is for tests and other callers of App.Run; Main never reads it.
func (ExitStatus) Cause ¶ added in v0.22.0
func (s ExitStatus) Cause() error
Cause returns the error that caused the App to exit, or nil if there is none.
The cause exists so that tests and other callers of App.Run can assert why an App exited, for example with errors.Is. Main ignores it and prints nothing, so an App reports anything a user should see on its own stderr.
func (ExitStatus) Code ¶ added in v0.22.0
func (s ExitStatus) Code() int
Code returns the exit code, which is the exit status of the OS process. It is always in 0..255.
type OSEnvironment ¶ added in v0.22.0
type OSEnvironment struct{}
OSEnvironment is an Environment backed by the environment of the current OS process. It is the Environment of OSProcess.
type Process ¶ added in v0.22.0
type Process struct {
// Args holds the command-line arguments, starting with the program
// name, like os.Args. It is required.
Args []string
// Stdin is the standard input of the process. It is required.
Stdin io.Reader
// Stdout is the standard output of the process. It is required, and
// it must be safe for concurrent writes if the App writes to it from
// several goroutines, as an *os.File is.
Stdout io.Writer
// Stderr is the standard error of the process. It is required, and it
// must be safe for concurrent writes if the App writes to it from
// several goroutines, as an *os.File is.
Stderr io.Writer
// Environment provides the environment variables of the process. It
// is required: OSEnvironment{} in production and a
// [StaticEnvironment] in tests.
Environment Environment
// NotifyContext delivers signals to the process, with the semantics of
// signal.NotifyContext, which is what [OSProcess] sets it to. It is
// required.
//
// It returns a copy of parent that is done when one of signals
// arrives, when stop is called, or when parent is done, whichever
// happens first. Listing no signals means every signal. When a signal
// cancels ctx, context.Cause(ctx) reports an error whose message is
// "<signal> signal received", such as "interrupt signal received".
//
// os.Kill and SIGSTOP cannot be caught, so listing them has no effect.
// stop must be called, typically with defer, to release the
// registration. A signal that arrives after stop is called gets its
// default action again, which for an interrupt is to exit.
NotifyContext func(parent context.Context, signals ...os.Signal) (ctx context.Context, stop context.CancelFunc)
}
Process is what exec hands a process: its arguments, its environment, its standard streams and the delivery of its signals. It is the one value through which an App reaches the OS process it runs in.
Every field is required, and the zero Process is invalid. This is documented rather than checked at runtime: a complete Process comes from OSProcess in production and from a test fake in tests, so code that receives a Process may use any field without a nil check.
type StaticEnvironment ¶ added in v0.22.0
type StaticEnvironment struct {
// contains filtered or unexported fields
}
StaticEnvironment is an Environment backed by a fixed set of values. It is the Environment of a hand-built Process in tests.
A StaticEnvironment is immutable, so it is safe for concurrent use. Build one with NewStaticEnvironment; the zero StaticEnvironment has no variables set.
func NewStaticEnvironment ¶ added in v0.22.0
func NewStaticEnvironment(values map[string]string) StaticEnvironment
NewStaticEnvironment returns a StaticEnvironment holding a copy of values. Changing values afterwards does not change the StaticEnvironment.
type ValueNotFoundError ¶ added in v0.22.0
type ValueNotFoundError struct {
// Name is the name of the variable that is not set.
Name string
}
ValueNotFoundError is returned by an Environment when a variable is not set.
func (ValueNotFoundError) Error ¶ added in v0.22.0
func (e ValueNotFoundError) Error() string
Error implements the error interface.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package bedrocktest provides a hermetic bedrock.Process for tests that run a whole bedrock.App, in the way net/http/httptest provides servers for tests of HTTP handlers.
|
Package bedrocktest provides a hermetic bedrock.Process for tests that run a whole bedrock.App, in the way net/http/httptest provides servers for tests of HTTP handlers. |
|
circuit
module
|
|
|
Package config reads configuration values that are composed by chaining.
|
Package config reads configuration values that are composed by chaining. |
|
example
|
|
|
httpserver
command
Httpserver is a complete HTTP service built on bedrock with the standard library alone.
|
Httpserver is a complete HTTP service built on bedrock with the standard library alone. |
|
fit
module
|
|
|
host
module
|
|
|
retry
module
|