engine

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: 37 Imported by: 0

Documentation

Overview

Package engine brings a bay up and takes it down.

Everything devbay creates in Docker carries the labels in this file. That is not bookkeeping: teardown is defined as "remove everything with these labels", so completeness is a property of the labelling rather than of remembering to delete each thing. A resource devbay creates without a label is a resource devbay will leak, which is why creation goes through one place.

Index

Constants

View Source
const (
	LabelManaged = "dev.devbay.managed"
	LabelProject = "dev.devbay.project"
	LabelBay     = "dev.devbay.bay"
	LabelService = "dev.devbay.service"
)

Labels applied to every container, network and volume devbay creates.

View Source
const LabelSeed = "dev.devbay.seed"

LabelSeed marks a template volume, which is project-scoped rather than bay-scoped: that is the whole point of it, and it is also why teardown has to treat it specially.

View Source
const WorkspaceDir = "/workspace"

WorkspaceDir is where the worktree is mounted inside every container.

Variables

This section is empty.

Functions

func PortKeys

func PortKeys(m *manifest.Manifest) []string

PortKeys lists every publishable port in a manifest, sorted.

Sorted, because the mapping from key to port must depend only on what the manifest declares. Ranging over the services map directly would reassign ports on every boot and quietly undo the determinism the allocator exists to provide.

Types

type Endpoint

type Endpoint struct {
	Host string
	Port int
}

Endpoint is where one service can be reached from a given plane.

func (Endpoint) Addr

func (e Endpoint) Addr() string

Addr renders the endpoint as host:port.

type Engine

type Engine struct {

	// Log receives progress lines. Never nil after New.
	Log func(format string, args ...any)
	// contains filtered or unexported fields
}

Engine operates on one bay.

func New

func New(ctx context.Context, opts Options) (*Engine, error)

New connects to Docker and prepares an engine for one bay.

func (*Engine) Close

func (e *Engine) Close() error

Close releases the Docker connection.

func (*Engine) Cool

func (e *Engine) Cool(ctx context.Context) error

Cool stops the bay's containers, keeping volumes.

This is the state transition a scheduler under memory pressure should make. A frozen bay costs the same memory as a running one.

func (*Engine) Down

func (e *Engine) Down(ctx context.Context) error

Down removes every Docker object belonging to this bay.

Teardown is expressed as a label query rather than as a list of things devbay remembers creating, because the two can disagree — after a crash, or a partial boot — and only the label query is right in both cases. A leaked volume or network after teardown is a bug of the same severity as a crash: it silently changes the behaviour of the next bay.

func (*Engine) Focus

func (e *Engine) Focus(ctx context.Context, focused bool) error

Focus marks this bay as the holder of the project's canonical hostname.

Named hostnames cover most work, but some things cannot be talked out of a fixed address: an OAuth redirect URI the provider will not wildcard, a mobile simulator, a native app config. Focus is what serves those.

func (*Engine) Focused

func (e *Engine) Focused() bool

Focused reports whether this bay holds the canonical hostname.

func (*Engine) Freeze

func (e *Engine) Freeze(ctx context.Context) error

Freeze pauses every container in the bay.

Use this to stop a bay consuming CPU while keeping resume instant. Do not use it to relieve memory pressure: see StateFrozen.

func (*Engine) HasContainers added in v0.4.0

func (e *Engine) HasContainers(ctx context.Context) bool

HasContainers reports whether this bay has anything the daemon knows about.

The difference between the two ways a boot fails. An image that cannot be pulled leaves nothing behind, so there is nothing to look at and a bay kept for inspection would be an empty shell; a service that started and never became healthy leaves the container that holds the explanation.

func (*Engine) Logs

func (e *Engine) Logs(ctx context.Context, service string, n int) (string, error)

Logs returns the last n lines from a service, as a single string.

func (*Engine) Memory

func (e *Engine) Memory(ctx context.Context) (uint64, error)

Memory reports the resident memory of the bay's containers, in bytes.

Measured inside the VM, because on macOS the host-side figure is the virtual machine's own footprint and says almost nothing about any individual bay. A scheduler budgeting against the host number would be budgeting against noise.

func (*Engine) NetworkName

func (e *Engine) NetworkName() string

NetworkName is the Docker network a bay's containers share. The proxy joins it to reach services by name.

func (*Engine) RebuildService added in v0.1.1

func (e *Engine) RebuildService(ctx context.Context, name string) error

RebuildService rebuilds a service's image and replaces its container.

The action behind `watch_action: rebuild`, for a service whose code is baked into the image rather than read from the mount -- a compiled binary, an asset bundle. Restarting one of those reruns the old build.

func (*Engine) Reload added in v0.1.1

func (e *Engine) Reload(ctx context.Context, name string) error

Reload applies the action a service declares for a changed file.

func (*Engine) Republish

func (e *Engine) Republish(ctx context.Context) error

Republish re-joins the proxy to this bay's network and re-announces its routes.

For the case where the proxy container is newer than the bay: a machine restart, a `docker rm`, an upgrade. The bay is still running and still answers on its published ports, but the proxy that knew how to reach it is gone, so the hostname a developer has bookmarked would 404 with no way back short of recreating the bay.

func (*Engine) Resolver

func (e *Engine) Resolver() *Resolver

Resolver exposes the address resolver, populated with published ports as containers start.

func (*Engine) RestartService added in v0.1.1

func (e *Engine) RestartService(ctx context.Context, name string) error

RestartService restarts one service and waits for it to be healthy again.

The action behind `watch_action: restart`, which is what a process that reads its configuration or its code once at startup needs when a file changes. Health is re-probed because a restart is exactly when a change breaks a service, and reporting "restarted" for a container that came back and immediately died would be worse than saying nothing.

func (*Engine) Resume added in v0.1.1

func (e *Engine) Resume(ctx context.Context) error

Resume brings a bay back from whichever resting state it is in.

"Thaw" is what a developer types to get a bay working again, and they should not have to know whether it was paused or stopped to type the right thing. Unpausing a stopped bay silently did nothing: the command reported the bay's state, which was still cold, and `devbay cool` was a one-way door out of a working bay -- the opposite of what it is documented to be, since cooling is exactly what a machine under memory pressure should do.

func (*Engine) RunTask

func (e *Engine) RunTask(ctx context.Context, taskName string) (*TaskResult, error)

RunTask materializes what a task needs, runs it, and parses the result.

The materialization is the point of making `needs` mandatory: a task that declares no services boots no containers and returns in the time the tests take, rather than the time a full stack takes to come up.

func (*Engine) State

func (e *Engine) State(ctx context.Context) (State, error)

State reports the bay's current state.

func (*Engine) Status

func (e *Engine) Status(ctx context.Context) ([]ServiceStatus, error)

Status reports every container belonging to this bay.

func (*Engine) Thaw

func (e *Engine) Thaw(ctx context.Context) error

Thaw resumes a frozen bay. Nothing is lost, so no health probe is needed -- the processes never stopped, they were only descheduled.

func (*Engine) URLs

func (e *Engine) URLs() map[string]string

URLs returns the browser-facing address of every routable service.

func (*Engine) Up

func (e *Engine) Up(ctx context.Context, plan *Plan) error

Up brings the plan's services up, wave by wave.

Services within a wave start concurrently and the wave is not complete until every service in it is healthy, so a slow database delays only what depends on it. A oneshot completes when it exits zero; a long-running service completes when its probe passes.

func (*Engine) Warm

func (e *Engine) Warm(ctx context.Context, plan *Plan) error

Warm restarts a cold bay and waits for it to be healthy again.

Unlike Thaw this does need probing: the processes really did exit, and a container that starts is not the same thing as a service that works.

type Options

type Options struct {
	Manifest *manifest.Manifest
	Bay      string
	Worktree string
	Log      func(format string, args ...any)

	// Ports assigns stable host ports. When nil, Docker chooses ephemeral
	// ones, which is fine for a throwaway bay but means a URL does not
	// survive a restart.
	Ports *ports.Allocator

	// Proxy publishes the bay's hostnames. When nil, the bay is reachable at
	// 127.0.0.1:<port> only, which works for agents and probes but gives up
	// the per-bay browser origin.
	Proxy *proxy.Proxy

	// Scrubber removes secret values from anything the engine returns. When
	// nil, a shape-only scrubber is used: returning raw output would be a
	// worse default than a slightly over-eager one.
	Scrubber *scrub.Scrubber

	// Secrets resolves ${secret:path} at container spawn time. When nil, a
	// manifest referencing a secret fails to boot rather than starting with a
	// blank credential, which would fail later and less clearly.
	Secrets func(path string) (string, bool)

	// Egress enforces the per-service network allowlist. When nil, services
	// keep whatever network Docker gives them, which is everything -- so this
	// being nil is a decision, not a default.
	Egress *egress.Enforcer
}

Options configure an Engine.

type Plan

type Plan struct {
	Steps []Step
}

Plan is an ordered set of steps that brings some part of a bay up.

func BootPlan

func BootPlan(m *manifest.Manifest) (*Plan, error)

BootPlan returns the plan that brings the whole bay up.

func SeedPlan

func SeedPlan(m *manifest.Manifest, service string) (*Plan, []string, error)

SeedPlan returns the oneshots whose completion defines a service's seeded state, in dependency order, along with the globs that decide staleness.

func TaskPlan

func TaskPlan(m *manifest.Manifest, task string) (*Plan, error)

TaskPlan returns the plan that materializes only what a task needs.

This is the reason tasks declare `needs`. Because an agent calls a named task rather than a shell command, devbay knows in advance which services the run touches and can start those and nothing else -- so a unit suite with `needs: []` boots zero containers and returns in the time the test takes, rather than the time a full stack takes to come up.

func (*Plan) Services

func (p *Plan) Services() []string

Services returns the service names in the plan, in order.

func (*Plan) Waves

func (p *Plan) Waves() [][]Step

Waves groups the steps by dependency depth.

type Plane

type Plane int

Plane is the vantage point an address is resolved for.

A service has more than one address and they are not interchangeable. This is the detail most likely to be got wrong, and getting it wrong produces the specific, confusing failure where a page renders in the browser but server-side rendering of the same page fails.

const (
	// PlaneContainer is one container addressing another over the bay's
	// network, by service name. Ports are the ones the app actually listens
	// on, so nothing inside a bay needs port-offset awareness.
	PlaneContainer Plane = iota

	// PlaneHost is the daemon, the CLI, or an agent addressing a container
	// from outside, as 127.0.0.1:<published port>. Health probes always use
	// this plane.
	PlaneHost

	// PlaneBrowser is a browser addressing a container by hostname through the
	// proxy. Only ever produced for ${bay.<svc>.public_url}.
	//
	// Never usable by the daemon or by an application: *.localhost does not
	// resolve through getaddrinfo, so Go, Node, Python and Safari all fail on
	// it. Only Chrome, Firefox and curl special-case those names.
	PlaneBrowser
)

type Resolver

type Resolver struct {
	// Bay and Project form the hostname namespace.
	Bay     string
	Project string
	// TLD is the browser-facing suffix; "localhost" by default.
	TLD string
	// Scheme is https once the proxy has a trusted certificate.
	Scheme string
	// contains filtered or unexported fields
}

Resolver turns ${bay...} and ${secret:...} references into concrete values.

It is constructed per bay, after ports are known, and answers differently depending on which plane is asking.

func NewResolver

func NewResolver(m *manifest.Manifest, bay string) *Resolver

NewResolver builds a resolver for a bay.

func (*Resolver) Endpoint

func (r *Resolver) Endpoint(service string, plane Plane) (Endpoint, error)

Endpoint returns where service can be reached from plane.

func (*Resolver) Hostname

func (r *Resolver) Hostname(service string) string

Hostname returns the browser-facing name for a service.

The primary service claims the bare <bay>.<project>.<tld>; every other service is prefixed. Distinct hostnames are what give each bay its own browser origin, which is what stops two bays from sharing a cookie jar -- browsers key cookies by host and ignore the port, so without this, two bays of the same app overwrite each other's sessions.

func (*Resolver) NamedHostname

func (r *Resolver) NamedHostname(service, port string) string

NamedHostname returns the browser-facing name for a secondary port.

func (*Resolver) ResolveEnv

func (r *Resolver) ResolveEnv(env map[string]string, plane Plane) (map[string]string, error)

ResolveEnv renders a service's environment for the given plane.

Container environments are rendered with PlaneContainer, so a service talking to another uses the container network. Values an agent or the CLI sees are rendered with PlaneHost. public_url always renders as the browser origin regardless of plane, because that is what it means.

func (*Resolver) ResolveString

func (r *Resolver) ResolveString(v string, plane Plane) (string, error)

ResolveString expands every reference in v.

func (*Resolver) SetHostPort

func (r *Resolver) SetHostPort(service string, port int)

SetHostPort records the host port a service's primary port was published on.

func (*Resolver) SetNamedHostPort

func (r *Resolver) SetNamedHostPort(service, name string, port int)

SetNamedHostPort records the host port for one of a service's named ports.

func (*Resolver) SetSecrets

func (r *Resolver) SetSecrets(f func(path string) (string, bool))

SetSecrets installs the secret lookup. Kept as a function rather than a map so that values are fetched at spawn time and never held longer than needed.

type ServiceStatus

type ServiceStatus struct {
	Service  string
	State    string
	Health   string
	HostPort int
}

ServiceStatus is the observable state of one service.

type State

type State string

State is how much of a bay is resident.

The names come from the original design, but two of the cost claims attached to them were wrong and have been corrected here, because building a scheduler on the wrong ones would produce a tool that promises to reclaim memory and does not.

const (
	// StateCold means the containers exist but are stopped, or do not exist at
	// all. Volumes survive, so a cold bay keeps its data.
	//
	// This is the ONLY state that returns memory. Whether the host sees it
	// back is a separate question: on Apple's Virtualization.framework the
	// Linux VM does not return freed guest memory to macOS, so reclamation
	// there needs OrbStack or Docker's own VMM.
	StateCold State = "cold"

	// StateFrozen means the containers are paused with the cgroup freezer.
	//
	// Freezing stops scheduling, not allocation. CPU drops to zero and resume
	// is near-instant with no state lost, which is genuinely useful -- but the
	// memory stays exactly where it was. Anything that needs memory back must
	// go to cold instead.
	StateFrozen State = "frozen"

	// StateWarm means running and reachable at its own hostname.
	StateWarm State = "warm"

	// StateHot means running and additionally holding the project's canonical
	// hostname, so links and configs that hardcode it land here.
	StateHot State = "hot"

	// StateMixed means the containers disagree, usually mid-transition or
	// after a partial failure. Reported rather than papered over.
	StateMixed State = "mixed"
)

func (State) ReclaimsMemory

func (s State) ReclaimsMemory() bool

ReclaimsMemory reports whether entering this state frees the memory a bay was using. Only one state does, and a scheduler that assumes otherwise will pause bays forever while the machine keeps swapping.

type Step

type Step struct {
	Service string
	// Oneshot steps run to completion and the plan waits for exit 0.
	Oneshot bool
	// Wave is the dependency depth. Everything in a wave can start
	// concurrently; wave N starts once wave N-1 is healthy.
	Wave int
}

Step is one unit of a boot plan.

type TaskResult

type TaskResult struct {
	Task       string `json:"task"`
	ExitCode   int    `json:"exit_code"`
	DurationMS int64  `json:"duration_ms"`
	Total      int    `json:"total,omitempty"`
	// Always emitted, never omitted when zero. This is the agent-facing
	// answer to "did my change work", and a missing key is not the same
	// message as a zero: it makes a clean run indistinguishable from a run
	// whose results could not be read, and one of those means try again.
	Passed   int              `json:"passed"`
	Failed   int              `json:"failed"`
	Skipped  int              `json:"skipped"`
	Failures []report.Failure `json:"failures,omitempty"`
	// Output is the tail of the run, scrubbed. Present so a failure with no
	// parseable report is still actionable rather than opaque.
	Output string `json:"output,omitempty"`
	// Parsed reports whether structured results were available. An agent that
	// sees false knows the failure list is empty because nothing could be
	// parsed, not because nothing failed.
	Parsed bool `json:"parsed"`
}

TaskResult is what running a task produces.

Everything here is typed. An agent that receives {file, line, message} can open the file and fix the line; an agent that receives stdout has to guess at the runner's format, and it guesses differently each time.

func (*TaskResult) Succeeded

func (r *TaskResult) Succeeded() bool

Succeeded reports whether the task passed.

Jump to

Keyboard shortcuts

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