manifest

package
v0.5.2 Latest Latest
Warning

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

Go to latest
Published: Aug 13, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

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

Constants

This section is empty.

Variables

View Source
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

func (a *Argv) UnmarshalYAML(value *yaml.Node) error

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

func (b *Build) UnmarshalYAML(value *yaml.Node) error

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

func Load(path string) (*Manifest, error)

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 Parse

func Parse(b []byte) (*Manifest, error)

Parse decodes a manifest from YAML bytes.

func (*Manifest) PrimaryService

func (m *Manifest) PrimaryService() string

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"
)

func (Restart) Valid added in v0.3.0

func (r Restart) Valid() bool

Valid reports whether r is a policy Docker accepts.

type Result

type Result struct {
	Diagnostics []Diagnostic
}

Result is the outcome of validating a manifest.

func Validate

func Validate(m *Manifest) *Result

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) Err

func (r *Result) Err() error

Err returns a single error summarising the rejections, or nil.

func (*Result) Errors

func (r *Result) Errors() []Diagnostic

Errors returns only the findings that reject the manifest.

func (*Result) OK

func (r *Result) OK() bool

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
	ScopeShared Scope = "shared" // one total, forked per bay
)

type Seed

type Seed struct {
	After   []string `yaml:"after"`
	Sources []string `yaml:"sources"`
}

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.

func (*Service) Command

func (s *Service) Command() Argv

Command returns the argv that launches s, whichever field holds it.

func (*Service) IsOneshot

func (s *Service) IsOneshot() bool

IsOneshot reports whether s runs to completion rather than staying up.

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
)

func (Severity) String

func (s Severity) String() string

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"
)

Jump to

Keyboard shortcuts

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