Documentation
¶
Overview ¶
Package manifest parses and validates devbay.yaml.
This package is the airlock. Everything upstream of it — an introspection agent reading a repository, a human editing YAML, a pull request — is untrusted. Everything downstream of it interprets a Manifest as instructions while holding credentials. A manifest that does not pass Validate must never reach the execution plane.
The rules the validator enforces are numbered R1-R7 and documented on the constructs they govern in spec/devbay.schema.json. The load-bearing one is R1: commands are argv arrays, never shell strings, and are executed with execve rather than sh -c. That single constraint is what makes it safe to have a language model author this file, because there is no path from a manifest to arbitrary shell.
Index ¶
- Variables
- func EmulatorNames() []string
- type Argv
- type Build
- type Diagnostic
- type Emulator
- type External
- type Fork
- type Health
- type Kind
- type Manifest
- type Mint
- type Mount
- type Report
- type ReportFormat
- type Restart
- type Result
- type Scope
- type Seed
- type Service
- type Severity
- type Supervision
- type Task
- type WatchAction
Constants ¶
This section is empty.
Variables ¶
var Emulators = map[string]Emulator{ "mailpit": { Image: "axllent/mailpit:latest", Port: 8025, Ports: map[string]int{"smtp": 1025}, Health: &Health{TCP: 8025}, Why: "catches SMTP and serves the messages at the bay's hostname", }, "stripe-mock": { Image: "stripe/stripe-mock:latest", Port: 12111, Health: &Health{TCP: 12111}, Why: "answers the Stripe API without a key or a network call", }, "minio": { Image: "minio/minio:latest", Port: 9000, Ports: map[string]int{"console": 9001}, Cmd: Argv{"server", "/data", "--console-address", ":9001"}, Health: &Health{ HTTP: "/minio/health/live", }, Env: map[string]string{ "MINIO_ROOT_USER": "devbay", "MINIO_ROOT_PASSWORD": "devbaydevbay", }, Why: "S3-compatible object storage, with a console at the bay's hostname", }, }
Emulators is the catalogue. Deliberately short: an entry here is a promise that the image, the ports and the probe are right, and a wrong entry is worse than no entry because it fails inside somebody else's application.
Functions ¶
func EmulatorNames ¶ added in v0.2.0
func EmulatorNames() []string
EmulatorNames lists the catalogue, sorted.
Types ¶
type Argv ¶
type Argv []string
Argv is a command as an argument vector.
R1. Declared as a slice so that a YAML string fails to decode structurally rather than being coerced — the type system does the first half of the enforcement and Validate does the rest.
func (*Argv) UnmarshalYAML ¶ added in v0.1.1
UnmarshalYAML requires every element to be a string.
YAML resolves bare `true`, `5` and `null` to a boolean, an integer and a null, and the decoder will happily coerce those into "true", "5" and "". That coercion is how `run: ["true"]` came to mean the /bin/true command by accident: it reads as the boolean, and a reader has to know YAML's scalar rules to see why it works. It also made devbay accept manifests the published schema rejects, which is the one divergence the schema exists to prevent -- a third party reimplementing from the spec would refuse a file devbay runs.
So the value has to be written the way it is meant: ["true"], ["5"].
type Build ¶
type Build struct {
Context string `yaml:"context,omitempty"`
Dockerfile string `yaml:"dockerfile,omitempty"`
Target string `yaml:"target,omitempty"`
// Args are build arguments, and they routinely decide what the image
// contains rather than merely how it is labelled: a Dockerfile that takes
// ARG NODE_ENV and runs `npm ci` installs the development dependencies or
// skips them on the strength of that one value. Dropping them produced an
// image that built successfully and then exited 127 because the command
// the compose file runs was never installed.
Args map[string]string `yaml:"args,omitempty"`
}
Build builds an image from the repo instead of pulling one.
func (*Build) UnmarshalYAML ¶
UnmarshalYAML accepts `build: ./dir` as well as the full mapping.
The shorthand is what Compose uses and therefore what people write, what every example they have seen uses, and what devbay's own `init` suggests when it finds a service that builds from source. Rejecting it produced the worst kind of error: a type-mismatch message that blamed R1, the rule about commands being argv arrays, for a field that has nothing to do with commands.
Accepting a scalar here does not weaken anything. Unlike a command, a context path is not executed, and it is confined to the worktree before use.
type Diagnostic ¶
type Diagnostic struct {
Severity Severity
Rule string // "R1".."R7", or "" for structural findings
Path string // e.g. services/api/env/DATABASE_URL
Msg string
Argv Argv // populated for Approval findings, shown verbatim to the human
}
Diagnostic is one finding, located in the manifest.
func (Diagnostic) Location ¶
func (d Diagnostic) Location() string
Location returns the manifest path a diagnostic refers to, with a rule prefix when one applies. Used by the CLI; Diagnostic.String is for logs.
func (Diagnostic) String ¶
func (d Diagnostic) String() string
type Emulator ¶ added in v0.2.0
type Emulator struct {
// Image is digest-free on purpose: these are devbay's choices rather than
// the repository's, and pinning them here would mean a devbay release to
// take a patch to a mail catcher.
Image string
// Port is the one that gets the bay hostname.
Port int
// Ports are the rest, each of which gets its own subdomain -- a mail
// catcher's interface and its SMTP port are separately addressable, which
// is the whole reason a service may declare more than one.
Ports map[string]int
// Health is how devbay knows it is ready.
Health *Health
// Cmd overrides the image's entrypoint where the emulator needs it.
Cmd Argv
Env map[string]string
// Why is shown when a developer asks what they are getting.
Why string
}
Emulator is a local stand-in for a third-party service.
The catalogue exists so `externals:` is a decision rather than research. A developer writing `emulate: mailpit` should not also have to know that it listens on 1025 and serves its interface on 8025, and getting either wrong produces a bay that boots and quietly cannot send mail.
type External ¶
type External struct {
Emulate string `yaml:"emulate"`
Real string `yaml:"real,omitempty"` // "never" or "gated"; there is no "always"
Mint *Mint `yaml:"mint,omitempty"`
}
External is a third-party dependency and how it is satisfied locally.
The default is always the emulator. This is the highest-leverage security decision in the format: most bays then need zero real credentials, and the ones that do are a short list a human actually looked at.
type Fork ¶
type Fork string
Fork is how per-bay data isolation is achieved for a stateful service.
const ( // ForkImage bakes the seeded state into a per-project image; each bay runs // its own container and the writable layer provides copy-on-write. This is // the default because it is instant, parallelisable, and makes a leaked // fork structurally impossible — teardown is a container removal. ForkImage Fork = "image" // ForkTemplate is Postgres CREATE DATABASE ... TEMPLATE. Note this is a // full O(size) physical copy, not copy-on-write, and no other session may // be connected to the template while it runs, so forks serialize. ForkTemplate Fork = "template" ForkPrefix Fork = "prefix" // key namespace prefix, e.g. Redis ForkSchema Fork = "schema" // separate schema in one database ForkNone Fork = "none" // genuinely shared; warned about )
type Health ¶
type Health struct {
HTTP string `yaml:"http,omitempty"` // path probed on the primary port
TCP int `yaml:"tcp,omitempty"` // port that must accept a connection
Cmd Argv `yaml:"cmd,omitempty"` // exec in container; healthy on exit 0
// Log is an RE2 regex matched against stdout and stderr. Preferred for
// processes with no port, and often better than an HTTP probe for dev
// servers, which print an explicit ready line: Vite emits "ready in 412
// ms", Sidekiq "Starting processing, hit Ctrl-C to stop", Celery
// "celery@host ready.". Matching that is real readiness.
Log string `yaml:"log,omitempty"`
// Process is liveness only: healthy while the main process runs. The
// weakest probe available, and Validate warns on it. It exists because the
// alternative — inventing a fake HTTP endpoint for a Sidekiq or Celery
// worker — is a lie that silently defeats the verification loop.
Process bool `yaml:"process,omitempty"`
Timeout string `yaml:"timeout,omitempty"`
Interval string `yaml:"interval,omitempty"`
StartPeriod string `yaml:"start_period,omitempty"`
}
Health is a readiness probe. Exactly one of the five forms must be set.
R5. Without a health probe there is no verification loop, and without a verification loop auto-detection is only guessing.
Probes run from the host against 127.0.0.1:<allocated port>, never against a *.localhost hostname — the daemon's own resolver cannot resolve those, because macOS does not honour RFC 6761 for subdomains of localhost and only Chrome, Firefox and curl special-case them.
type Kind ¶
type Kind string
Kind distinguishes a long-running service from a one-shot step.
const ( // KindService is a long-running process. Requires a health probe (R5). KindService Kind = "service" // KindOneshot runs to completion and is healthy on exit code 0. // // Oneshot exists because every real repository has ordering-sensitive // setup — migrate, then seed, then start the app — and a flat list of // setup commands gets the ordering wrong: migrations must finish before // the app starts, not after every service is healthy. Modelling each step // as a oneshot means `needs` alone expresses the ordering, and there is // one dependency mechanism in the manifest rather than two. KindOneshot Kind = "oneshot" )
type Manifest ¶
type Manifest struct {
Version int `yaml:"version"`
Project string `yaml:"project"`
Services map[string]*Service `yaml:"services"`
Tasks map[string]*Task `yaml:"tasks"`
Externals map[string]*External `yaml:"externals,omitempty"`
Supervision *Supervision `yaml:"supervision,omitempty"`
// Path is the file this manifest was loaded from. Not part of the format.
Path string `yaml:"-"`
}
Manifest is a parsed devbay.yaml.
func Load ¶
Load reads and decodes a devbay.yaml. It does not validate; call Validate.
Decoding is strict: an unknown key is an error rather than being silently ignored, because a typo in a security-relevant key — egress, install_scripts — must never be read as "the author didn't set it".
func (*Manifest) PrimaryService ¶
PrimaryService returns the name of the service that claims the bare <bay>.<project> hostname. It is inferred when exactly one long-running service exposes a port, and declared otherwise. Validate guarantees one exists, so this does not report an error.
type Mint ¶
type Mint struct {
Provider string `yaml:"provider,omitempty"` // aws-sts, github-app, gcp-sa
TTL string `yaml:"ttl,omitempty"`
Scope map[string]any `yaml:"scope,omitempty"`
}
Mint describes how to issue a short-lived scoped credential instead of passing a long-lived one through.
type Mount ¶
type Mount struct {
// Source is relative to the repository root and confined to it.
Source string `yaml:"source"`
// Target is an absolute path inside the container.
Target string `yaml:"target"`
}
Mount is a directory from the repository, bound into the container.
type Report ¶
type Report struct {
Format ReportFormat `yaml:"format"`
Path string `yaml:"path,omitempty"`
}
Report says where a runner writes results. Path is empty for formats that stream to stdout rather than writing a file, notably go-json.
type ReportFormat ¶
type ReportFormat string
ReportFormat is the machine-readable shape a test runner emits.
There is no cross-language standard for agent-consumable test results. JUnit XML is the universal fallback; native streaming JSON is preferred where it exists because it gives live progress a report file cannot.
const ( ReportJUnit ReportFormat = "junit" // pytest, phpunit, vitest natively ReportJSON ReportFormat = "json" // jest --json, vitest --reporter=json ReportGoJSON ReportFormat = "go-json" // go test -json, a streaming event log ReportTAP ReportFormat = "tap" )
type Restart ¶ added in v0.3.0
type Restart string
Restart is a container restart policy, spelled the way Docker spells it.
const ( // RestartNo is the default: a service that exits stays exited, and the // boot reports it. Anything else hides a crash loop behind a healthy bay. RestartNo Restart = "no" RestartOnFailure Restart = "on-failure" RestartAlways Restart = "always" RestartUnlessStopped Restart = "unless-stopped" )
type Result ¶
type Result struct {
Diagnostics []Diagnostic
}
Result is the outcome of validating a manifest.
func Validate ¶
Validate applies rules R1-R7 and the semantic checks a schema cannot express.
This function is the airlock. A manifest that does not pass it must never be interpreted by anything holding credentials.
func (*Result) Approvals ¶
func (r *Result) Approvals() []Diagnostic
Approvals returns the commands needing one-time human approval.
func (*Result) Errors ¶
func (r *Result) Errors() []Diagnostic
Errors returns only the findings that reject the manifest.
func (*Result) OK ¶
OK reports whether the manifest may cross into the execution plane. Approvals do not make a manifest invalid; they gate execution of one command.
func (*Result) Warnings ¶
func (r *Result) Warnings() []Diagnostic
Warnings returns the advisory findings.
type Scope ¶
type Scope string
Scope controls how many instances of a service exist.
const ( ScopeBay Scope = "bay" // one per bay; the normal case, including datastores )
type Seed ¶
Seed defines the state captured into a service's image for ForkImage.
Seeding names oneshot services rather than carrying commands, because a migration does not run inside the database container: it runs inside the application container, against the database, using the application's own toolchain. A command here would have no unambiguous place to execute.
type Service ¶
type Service struct {
Kind Kind `yaml:"kind,omitempty"`
Image string `yaml:"image,omitempty"`
Build *Build `yaml:"build,omitempty"`
Scope Scope `yaml:"scope,omitempty"`
Fork Fork `yaml:"fork,omitempty"`
Seed *Seed `yaml:"seed,omitempty"`
Workdir string `yaml:"workdir,omitempty"`
Install Argv `yaml:"install,omitempty"`
// InstallScripts permits package lifecycle scripts during Install.
//
// False by default: devbay appends --ignore-scripts or the ecosystem
// equivalent. Running install scripts on a freshly cloned untrusted
// repository is the delivery mechanism used by the self-replicating
// Shai-Hulud npm worm, which steals cloud credentials. Setting this true
// is approval-gated.
InstallScripts bool `yaml:"install_scripts,omitempty"`
Start Argv `yaml:"start,omitempty"` // long-running services
Run Argv `yaml:"run,omitempty"` // oneshots
// Labels are container labels, passed through to the daemon.
//
// Load-bearing far more often than they look: a reverse proxy that
// discovers its backends by label routes to nothing without them, and the
// stack comes up healthy and answers 404. devbay's own labels are how
// teardown finds what to remove, so those win on a collision.
Labels map[string]string `yaml:"labels,omitempty"`
// DockerSocket binds the host's Docker daemon socket into the service.
//
// Off by default and approval-gated, because a container that can reach
// the daemon can start any other container on the machine, with any image
// and any mount -- the bay is then isolated from nothing, and neither is
// the rest of the developer's machine. It exists because refusing outright
// made devbay unable to run things Docker runs: a reverse proxy that reads
// container labels, a container manager, and above all a test suite that
// starts its own containers. An orchestration layer that cannot orchestrate
// those is not finished; one that hands the daemon over silently is not
// safe. So it is written down, and a human agrees to it once.
DockerSocket bool `yaml:"docker_socket,omitempty"`
// Restart is what to do when the process exits.
//
// Present because real compose files depend on it. A stack whose web
// service talks to a cache it does not declare a dependency on races the
// cache at startup, and the compose file handles that with
// `restart: on-failure` rather than with depends_on -- which means
// transcribing the file without this field produces a stack that dies
// where the original recovers. devbay cannot infer the missing dependency
// from the file, so it honours the mechanism the file actually uses.
Restart Restart `yaml:"restart,omitempty"`
// Port is the primary port: the one that gets a hostname and, unless
// overridden, the one an http probe targets. Exactly one exists per
// service so hostname routing is unambiguous.
Port int `yaml:"port,omitempty"`
// Ports are additional named ports. Real services routinely expose more
// than one — a mail catcher listens on SMTP and serves a web UI, an object
// store serves an API and a console.
Ports map[string]int `yaml:"ports,omitempty"`
Primary bool `yaml:"primary,omitempty"`
Needs []string `yaml:"needs,omitempty"`
Health *Health `yaml:"health,omitempty"`
// Watch globs are evaluated by the daemon on the host using native
// FSEvents or inotify, never by a watcher inside the container: virtiofs
// does not implement inotify, so host edits do not reliably produce
// events in a container, and polling costs real CPU per watcher per bay.
Watch []string `yaml:"watch,omitempty"`
WatchAction WatchAction `yaml:"watch_action,omitempty"`
// Mounts bind a directory from the repository over a path in the
// container.
//
// Needed by the common `target: dev` pattern, where an image builds the
// source in and the source is then bound back over the top so edits are
// live. Declared rather than inferred: devbay tried inferring it from the
// build context and got it right for interpreted images and wrong for
// compiled ones, where the same directory holds build output and mounting
// source over it hides the binary.
Mounts []Mount `yaml:"mounts,omitempty"`
// Volumes are paths backed by a named volume rather than the bind mount —
// node_modules, .venv, vendor/bundle, target, .next. Not an optimisation
// to defer: a bind-mounted dependency tree runs at roughly 2.5x native on
// macOS and a named volume recovers most of that.
Volumes []string `yaml:"volumes,omitempty"`
// Egress is the outbound allowlist. Absent or empty means no outbound
// network at all.
//
// R4. This field is never authorable by the introspection agent: the
// validator strips it from model-produced manifests. If a model could
// write the allowlist, a prompt injection would append its own
// destination and the sandbox would defeat itself.
Egress []string `yaml:"egress,omitempty"`
Env map[string]string `yaml:"env,omitempty"`
// Provided marks a service devbay generated from its own emulator
// catalogue rather than one the repository declared. Not part of the
// format: it exists so R2's approval prompt stays about repository
// content. Asking a developer to approve an argv devbay chose for them
// trains them to approve prompts, which is the opposite of what the rule
// is for.
Provided bool `yaml:"-"`
}
Service is a container in a bay.
type Severity ¶
type Severity int
Severity distinguishes a manifest that must be rejected from one that is merely questionable.
const ( // Error means the manifest does not reach the execution plane. Error Severity = iota // Warn means it does, but a human should see the message. Two things // produce warnings rather than errors: an argv[0] outside the allowlist, // which is permitted subject to approval, and a liveness-only health // probe, which is weak but honest. Warn // Approval means execution is blocked until a human approves the exact // argv. This is the R2 escape hatch: rejecting outright would make people // fork the project, and permitting a shell string would make R1 theatre. Approval )
type Supervision ¶
type Supervision struct {
Banner *bool `yaml:"banner,omitempty"`
FaviconTint *bool `yaml:"favicon_tint,omitempty"`
}
Supervision controls the per-bay identity surface injected by the proxy.
type Task ¶
type Task struct {
Run Argv `yaml:"run"`
// Needs is the service subgraph this task requires. An empty slice is
// valid and common: a unit suite that boots zero containers is the
// fastest path to a verified result.
//
// R6. Omitting the key entirely is an error rather than a default,
// because forcing the author to think about it is the point.
//
// The distinction is carried by nil versus empty: YAML decodes `needs: []`
// to an empty non-nil slice and an omitted key to nil, so no side channel
// is needed -- which also means a Task built in Go rather than parsed from
// a file can satisfy the rule, by writing []string{}.
Needs []string `yaml:"needs"`
In string `yaml:"in,omitempty"`
Report *Report `yaml:"report,omitempty"`
Env map[string]string `yaml:"env,omitempty"`
Timeout string `yaml:"timeout,omitempty"`
// contains filtered or unexported fields
}
Task is a named finite command an agent or human runs against a bay.
type WatchAction ¶
type WatchAction string
WatchAction is what devbay does when a watched path changes.
const ( WatchRestart WatchAction = "restart" WatchSync WatchAction = "sync" WatchRebuild WatchAction = "rebuild" )