web

package
v0.2.2 Latest Latest
Warning

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

Go to latest
Published: Oct 25, 2026 License: MIT Imports: 56 Imported by: 0

Documentation

Overview

Package web serves the pipeline UI: a read-and-operate view of what the runner has done and is doing, over the same sqlite store the CLI writes.

It is a second front end on the existing model, never a second model. Every page answers a question the store already holds the answer to — what jobs exist and how they depend on each other, what a run did step by step, why a step was skipped, what an agent actually said — and the two mutations it offers (enqueue a job, decide an approval) go through the same rows `steps web` and `steps approvals approve` use. Nothing here is a parallel execution path.

The server is single-user and binds loopback by default, where it asks for nothing: the thing it would authenticate against does not exist — this is the local runner's own UI, in the same trust domain as the terminal that started it. A deployment that is NOT that turns on HTTP Basic (see auth.go), which is the whole of the authentication here.

Index

Constants

View Source
const (
	LoginPending    = "pending"
	LoginAuthorized = "authorized"
	LoginFailed     = "failed"
)

Login states, which the CLI polls for.

View Source
const HealthPath = "/healthz"

HealthPath answers ok to anything that can reach the listener; a platform's health check sends no credentials, and liveness is all it can learn.

View Source
const MCPCallbackPath = "/mcp/callback"

MCPCallbackPath is where an authorization server redirects the browser to finish a login this daemon started; the CLI builds the redirect URI from it, so it is one constant.

Variables

View Source
var ErrNoSuchPipeline = errors.New("no such pipeline")

ErrNoSuchPipeline is a verb naming a pipeline this daemon does not hold.

View Source
var ErrPlainPassword = errors.New("a local user's password must be a bcrypt hash — make one with `htpasswd -nbB <name> <password>`")

ErrPlainPassword is a local user given in the clear rather than hashed.

View Source
var ErrRefused = errors.New("the pipeline was refused")

ErrRefused is the sender's to fix — unparseable, or naming something this machine cannot supply — which is why it is not a 500.

View Source
var ErrRevisionMoved = errors.New("the pipeline's configuration changed since it was read")

ErrRevisionMoved is a compare-and-set the daemon refused: the configuration moved between the sender's diff and its set.

Functions

func CheckAccess

func CheckAccess(access *config.Access) error

CheckAccess is whether a pipeline's access: rules compile, which is what a set is refused over when one cannot work.

func FormatBinaryBytes

func FormatBinaryBytes(n int64) string

FormatBinaryBytes renders a disk or transfer size in BINARY units, deliberately unlike formatBytes.

That one is decimal so an agent's payload is comparable to the byte limits it is bounded by. This is about disks and wire transfers, which every tool a reader will cross-check against — the EC2 console, df, docker system df — reports in KiB/MiB/GiB.

func FormatUSD

func FormatUSD(amount float64) string

FormatUSD renders a dollar figure at the precision an agent run actually costs. Four decimals rather than two: a single cheap step lands in the fractions of a cent, and rounding those to $0.00 loses exactly the number the column exists to show — the same reading an unpriced run must never produce.

Exported because the terminal report (`steps runs cost`) prints the same figures from the same rows, and two spellings of "how much" would disagree on exactly the cheap runs where the difference is the whole answer.

Types

type Authorizer

type Authorizer interface {
	StartLogin(pipeline *Pipeline, server string, req LoginRequest) (LoginStatus, error)
	LoginStatus(pipeline *Pipeline, server string) (LoginStatus, bool)
	// LoginCallback is the handler for the pending login that minted state, nil when none did.
	LoginCallback(state string) http.Handler
	// MCPState is what the token-holder knows about one server: its saved credential, and the last probe anybody asked for. Never a request of its own — the page calls this on every poll.
	MCPState(pipeline *Pipeline, server string) MCPState
	// StartProbe connects to one server in the background, recording the result for MCPState to report. Refuses what it cannot honestly probe; a probe already in flight is left alone rather than duplicated.
	StartProbe(pipeline *Pipeline, server string) error
}

Authorizer runs logins and reports what a declared mcp server is worth. An interface for the reason Manager is one — depguard keeps internal/mcp out of this package — and optional: a manager that is not one answers 501 rather than pretending.

type BrokenPipeline

type BrokenPipeline struct {
	Name   string
	SHA    string
	From   string
	Reason string
}

BrokenPipeline is one the database holds that this daemon could not serve, and why.

type Capacity

type Capacity interface {
	RunCapacity() (limit, active int)
}

Capacity is the daemon-wide run limit (0 when unlimited) and how many builds hold it.

type Exporter

type Exporter interface {
	ServeExport(ctx context.Context, target *scheduler.Pipeline, export config.MCPExport, hooks scheduler.ExportHooks) (*scheduler.ExportServer, error)
}

Exporter builds a pipeline's exports; scheduler.LocalRunner is one. Absent on a read-only server, which serves no exports: calling a tool is acting, the thing read-only withholds.

type Identity

type Identity struct {
	// Provider is where the name came from: local, github, google.
	Provider string
	// UserID is the name within it: a local account, a GitHub login, a Google address.
	UserID string
	// ID is the provider's own id for the account, which a rename does not change: GitHub's numeric user id, Google's subject, and for a local account its name. A rule that must not follow a renamed GitHub login onto whoever registers it next names this rather than UserID.
	ID            string
	Email         string
	Organizations []string
	Groups        []string
}

Identity is what a role's rule is written against.

type LocalUser

type LocalUser struct {
	Name string
	Hash []byte
}

LocalUser is one of the daemon's own accounts: a name and the bcrypt hash of its password, so the daemon never holds the password itself.

func ParseLocalUser

func ParseLocalUser(entry string) (LocalUser, error)

ParseLocalUser reads one "name:hash" entry, the format htpasswd -B writes. The error never repeats the entry: a plain-text password is exactly what it refuses.

func ParseLocalUsers

func ParseLocalUsers(r io.Reader) ([]LocalUser, error)

ParseLocalUsers reads an htpasswd file: one entry a line, blank lines and # comments skipped.

type LoginRequest

type LoginRequest struct {
	Base string `json:"base"`
	// Return is where the browser is sent once the callback has answered, and it is a path on THIS daemon — refused otherwise (see redirectFor's sibling). Empty is a login a terminal is waiting on, which keeps the callback's plain "you can close this window": the CLI is the thing that reports the outcome there. A page sets it because the exchange finishes after the callback has answered, so the failure that matters most has nowhere else to land.
	Return string `json:"return,omitempty"`
}

LoginRequest starts one. Base is the address the CLI reached this daemon on, userinfo already removed: it is proven to work, the browser finishing the flow sits beside that CLI, and it is what the redirect URI is built from — so a password left on it would be handed to the authorization server.

type LoginStatus

type LoginStatus struct {
	State string `json:"state"`
	// ID names THIS attempt, because a login is tracked per pipeline and server and a second Connect replaces the first under that pair. Without it a request can only ask "what is the state of this pipeline's login called tracker", which after a replacement is somebody else's.
	ID string `json:"id,omitempty"`
	// AuthorizeURL appears once discovery and registration are done, which is network work the start request does not wait out.
	AuthorizeURL string `json:"authorize_url,omitempty"`
	// Message is the refusal, in the flow's own words — including the one that matters most unattended: authorized, but with a token that cannot be renewed.
	Message string `json:"message,omitempty"`
}

LoginStatus is where one login stands. Shared with the client for the reason SetRequest is.

type MCPCredential

type MCPCredential struct {
	Connected bool
	// Detail is the sentence a reader acts on — "connected, renews automatically", "authorized for a different endpoint".
	Detail string
}

MCPCredential is what the daemon knows about an oauth server's saved token without spending it. It carries no part of the token: a page renders this.

type MCPProbe

type MCPProbe struct {
	// Running is a probe still in flight, which the page's own poll will replace with its result.
	Running bool
	OK      bool
	Detail  string
	At      time.Time
	// Tools is what the server answered with, which is the question behind the question: a grant names a tool, and whether that name still exists is what a reader is really checking.
	Tools []string
}

MCPProbe is the outcome of a Test: a live connection to the server, made because somebody asked for one and never on a page load.

type MCPState

type MCPState struct {
	Credential MCPCredential
	// Probe is the last Test of this server, nil when nobody has asked.
	Probe *MCPProbe
}

MCPState is everything the token-holder knows about one declared server. internal/web cannot import internal/mcp (depguard), and should not: a saved login is not this package's business, and the interface is what keeps it that way.

type Manager

type Manager interface {
	// Set applies a configuration under name, creating the pipeline if this
	// daemon does not hold one. req.ExpectSHA is the revision the sender
	// diffed against: empty means "I did not look", and a mismatch is refused
	// rather than applied over whatever arrived in between.
	Set(ctx context.Context, name string, req SetRequest) (SetResult, error)
	Destroy(ctx context.Context, name string) error
	Rename(ctx context.Context, from, to string) error
}

Manager applies what a `steps pipeline` verb asks for. An interface for the reason Runner is one: this package serves the surface and chooses neither a store driver nor a workspace provider.

type Option

type Option func(*Server)

Option configures a server at construction, which is where the middleware table is built: an auth setting applied afterwards would be a server that answered without it first.

func WithActivityLog

func WithActivityLog(open func() (store.Reader, error), limit int) Option

WithActivityLog says how to open the database the log lives in, for when every pipeline is gone and the log still holds what was done to them, and how many entries it keeps (zero is the default).

func WithAllowedHosts

func WithAllowedHosts(hosts []string) Option

WithAllowedHosts refuses every request whose Host is not one of these names or a loopback one. The operator's to give, because a reverse proxy forwards the client's Host, so only the operator knows which names are this daemon's; WithLoopbackHosts is the list implied when nobody gave one.

func WithBasicAuth

func WithBasicAuth(username, password string) Option

WithBasicAuth adds one account given in the clear. Both halves are the caller's to validate — see cli's WebCmd, where half a pair refuses to start.

func WithCapacity

func WithCapacity(capacity Capacity) Option

WithCapacity is where the follow page reads --max-runs from. A separate option rather than part of Runner because a read-only server has no Runner and still drains under the same limit.

func WithExporter

func WithExporter(exporter Exporter) Option

WithExporter lets the server serve exports, built by exporter.

func WithExternalURL

func WithExternalURL(base string) Option

WithExternalURL is the address people and agents reach the daemon on, which every OAuth URL it publishes is built from.

func WithLocalUsers

func WithLocalUsers(users []LocalUser) Option

WithLocalUsers adds hashed accounts; a name given twice is the caller's to refuse.

func WithLoopbackHosts added in v0.2.0

func WithLoopbackHosts(names ...string) Option

WithLoopbackHosts answers loopback names and these, for a daemon bound to loopback that was given no WithAllowedHosts: a browser can reach it by no other name, so one arriving under another is a rebinding page. The names are the ones it was told it is published at, which a proxy on this machine forwards. A list given with WithAllowedHosts replaces this one, in either order.

func WithRoles

func WithRoles(roles Roles) Option

WithRoles limits who may do what to these rules.

func WithSessions

func WithSessions(accounts store.Accounts) Option

WithSessions lets browsers sign in through a form, keeping their sessions in accounts.

func WithSignInProviders

func WithSignInProviders(providers ...SignInProvider) Option

WithSignInProviders adds the providers the sign-in page offers.

func WithTracing added in v0.2.0

func WithTracing(m echo.MiddlewareFunc) Option

WithTracing installs m ahead of every other middleware; it is how internal/cli traces requests without this package importing a telemetry SDK.

func WithVersion

func WithVersion(version string) Option

WithVersion is the build version the footer prints; "dev" when unset, the same default steps --version has.

type Pipeline

type Pipeline struct {
	*scheduler.Pipeline

	// Hooks answers POST /p/<slug>/hooks/<resource>, a delivery to a webhook resource. Built by the caller (trigger.HookHandler): this package serves the surface and does not own the queue — internal/scheduler drains it, reached only through Runner. Nil only in a test.
	Hooks func(w http.ResponseWriter, r *http.Request, resource string)
	// contains filtered or unexported fields
}

Pipeline is one held pipeline as this server sees it: the scheduler's handle (name, store, bus, served configuration) plus what only the HTTP surface needs.

func NewPipeline

func NewPipeline(slug, path string, cfg *config.Config, st store.Store, bus *events.Bus) *Pipeline

NewPipeline builds a served pipeline around the configuration it starts with. A literal would leave the embedded handle nil.

func (*Pipeline) Path

func (p *Pipeline) Path() string

Path is where the served configuration was set from.

func (*Pipeline) SetPath

func (p *Pipeline) SetPath(path string)

SetPath records where a set that just landed was sent from.

type PipelineConfig

type PipelineConfig struct {
	Name     string            `json:"name"`
	SHA      string            `json:"sha"`
	Source   string            `json:"source"`
	Includes map[string][]byte `json:"includes,omitempty"`
	From     string            `json:"from,omitempty"`
	Paused   bool              `json:"paused"`
}

PipelineConfig is what get prints and what set diffs against: the source, its includes (bytes, for the reason SetRequest's are), and the sha that names both.

type PipelineSummary

type PipelineSummary struct {
	Name   string `json:"name"`
	SHA    string `json:"sha"`
	From   string `json:"from,omitempty"`
	Jobs   int    `json:"jobs"`
	Paused bool   `json:"paused"`
	// Broken is why a pipeline the daemon holds is not being served — a restart could not restore it — and empty for one that is.
	Broken string `json:"broken,omitempty"`
}

PipelineSummary is one row of `steps pipeline list`. Exported and shared with the client for the reason SetRequest is: two copies of a wire struct drift into a silently-dropped field rather than a build error.

type PlacementView

type PlacementView struct {
	store.Placement
}

PlacementView is one placed step's machine as the template reads it.

Exported because `steps runs where` renders the same rows through it. Two spellings of "which machine" had already drifted: the CLI's copy never learned Volatile, so the terminal — where an operator debugging a placed step looks first — reported a tmpfs workdir as an ordinary disk.

func (PlacementView) Disk added in v0.2.0

func (p PlacementView) Disk() string

Disk is what the step's own volumes held on the worker, "-" where nothing weighed them: a worker that keeps no volumes is not one whose step wrote nothing.

func (PlacementView) Identity

func (p PlacementView) Identity() string

Identity is who the step ran as, blank when the worker did not say — never an invented 0, which would read as root.

func (PlacementView) Machine

func (p PlacementView) Machine() string

Machine names the host, and the image if the step ran in a container on it.

func (PlacementView) Platform

func (p PlacementView) Platform() string

Platform is what the worker reported itself to be.

func (PlacementView) Received

func (p PlacementView) Received() string

Received is what came back from it: the tree the step produced there. A worker keeps what it produces too, so this is the cost of a LOCAL reader wanting the bytes, not of the step having made them.

func (PlacementView) Sent

func (p PlacementView) Sent() string

Sent is what actually crossed to reach this machine. A worker keeps what it receives, so a step whose inputs were already there honestly reads 0 B.

type Role

type Role int

Role is what a person may do. Each includes the ones below it.

const (
	// RoleViewer reads every page.
	RoleViewer Role = iota + 1
	// RoleOperator acts on runs: trigger, retry, approve, answer, abort, release, pause.
	RoleOperator
	// RoleAdmin changes what the daemon serves: set, destroy, rename, connect an MCP server, and reads the activity log.
	RoleAdmin
)

func (Role) String

func (r Role) String() string

type Roles

type Roles struct {
	// contains filtered or unexported fields
}

Roles are compiled rules; the zero value grants everything to anyone signed in, which is a daemon that has not been told otherwise.

func CompileRoles

func CompileRoles(specs []string) (Roles, error)

CompileRoles reads "role=expression" rules. Compiled here, once, so a rule that cannot work stops the daemon before it serves anyone. Rules are expected about who is allowed, so an admin rule is required: without one nobody could set a pipeline, and the daemon would be stuck as it stands.

func (Roles) AllowsProviders

func (r Roles) AllowsProviders() error

AllowsProviders refuses rules too loose for a daemon that signs people in with GitHub or Google. Those let in anybody with an account there, so the default every other daemon gets — everyone signed in may do everything — would make the whole internet an admin, and a missing viewer rule would let all of it read. Checked where the CLI starts and again where the server is built, so neither path can serve a provider without saying who of its accounts may do what.

type Runner

type Runner interface {
	// Enqueue queues a person's trigger of a job, returning the queue row id.
	Enqueue(ctx context.Context, pipeline *scheduler.Pipeline, jobName, reason string, force bool) (int64, error)
	// EnqueueRerun queues a retry of a recorded run.
	EnqueueRerun(ctx context.Context, pipeline *scheduler.Pipeline, jobName, runID string) error
	// Abort cancels a run this process is executing, and reports false when it is not running here.
	Abort(pipeline *scheduler.Pipeline, runID string) bool
	// AbortQueued drops a job's queued run before it starts, and reports false when nothing was queued.
	AbortQueued(ctx context.Context, pipeline *scheduler.Pipeline, jobName string) (bool, error)
}

Runner is what the web layer needs in order to act rather than only report: enqueue a job for execution, and report what is currently running. Implemented by scheduler.LocalRunner, which internal/cli installs; an interface so the HTTP layer can be tested without starting real jobs.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server serves whatever has been set into it. The registry is behind a lock rather than fixed at construction because a handler, the drain and the poll loop all read it while another request writes it.

func New

func New(pipelines []*Pipeline, runner Runner, opts ...Option) (*Server, error)

New builds a server over whatever pipelines it is handed, which may be none: a daemon is configured by `steps pipeline set` and by nothing else, so empty is the ordinary starting state rather than an error.

func (*Server) Add

func (s *Server) Add(pipeline *Pipeline) error

Add starts serving a pipeline, refusing a name already held.

func (*Server) Broken

func (s *Server) Broken() []BrokenPipeline

Broken is every pipeline held and not served, by name.

func (*Server) BrokenReason

func (s *Server) BrokenReason(name string) (string, bool)

BrokenReason is why a pipeline is held and not served, and false when it is not broken.

func (*Server) ClearBroken

func (s *Server) ClearBroken(name string)

ClearBroken forgets a broken pipeline, once nothing holds it any more.

func (*Server) Close

func (s *Server) Close() error

Close lets go of every export's upstream connections. Pages need nothing closed; an export holds a live MCP connection per server it reaches.

func (*Server) Handler

func (s *Server) Handler() http.Handler

Handler exposes the router, for tests and for embedding.

func (*Server) Lookup

func (s *Server) Lookup(slug string) *Pipeline

Lookup is the pipeline served under slug, nil when this daemon holds none.

One return rather than the comma-ok a map gives, because the two say the same thing and a caller that reads the bool and keeps the pointer is exactly the shape a nil-flow analyzer cannot follow.

func (*Server) MarkBroken

func (s *Server) MarkBroken(broken BrokenPipeline)

MarkBroken lists a pipeline as held and not served.

func (*Server) Remove

func (s *Server) Remove(slug string) *Pipeline

Remove hands the pipeline back so the caller can shut down what it started for it, and nil when this daemon was not serving one.

func (*Server) Served

func (s *Server) Served() []*Pipeline

Served is what this daemon holds right now, by slug.

func (*Server) SetManager

func (s *Server) SetManager(manager Manager)

SetManager is separate from New because the manager needs the server it registers into.

func (*Server) Start

func (s *Server) Start(ctx context.Context, addr string) error

Start serves until ctx is canceled, then shuts down gracefully.

type SetRequest

type SetRequest struct {
	Source string `json:"source"`
	// A daemon has no sibling filesystem, so an include that does not travel here cannot be resolved at all (see config.Bundle); bytes rather than strings because encoding/json replaces each invalid UTF-8 byte of a string with U+FFFD, which ran a Latin-1 run_file: as bytes nobody sent.
	Includes map[string][]byte `json:"includes,omitempty"`
	// ExpectSHA is the compare-and-set. Empty means the sender did not look, which a script may legitimately do.
	ExpectSHA string `json:"expect_sha,omitempty"`
	// From is the SENDER's path, recorded for a reader wondering where a served configuration came from, and never opened here.
	From string `json:"from,omitempty"`
	// Pause is written with the revision, before the pipeline is started: a pause sent after the set is a second request, and the first poll — and whatever it triggers — can land between the two.
	Pause bool `json:"pause,omitempty"`
}

SetRequest is one upload: the substituted YAML, the files it includes, and the revision the sender believed it was replacing.

type SetResult

type SetResult struct {
	SHA string `json:"sha"`
	// Created, replaced and unchanged are three outcomes a person reads differently, so the answer says which.
	Created   bool `json:"created"`
	Unchanged bool `json:"unchanged"`
	// Repaired is a set that served a pipeline a restart had left broken.
	Repaired bool `json:"repaired,omitempty"`
}

SetResult is what the daemon did, so the terminal that asked can say so.

type SignInProvider

type SignInProvider struct {
	// Name is the provider's word in URLs and in an actor's kind ("github").
	Name string
	// Label is what the sign-in button says it continues with.
	Label string
	// contains filtered or unexported fields
}

SignInProvider is one way to sign in that the daemon does not keep the password for.

func GitHubSignIn

func GitHubSignIn(clientID, secret, externalURL string, client *http.Client) SignInProvider

GitHubSignIn signs people in with GitHub, reading their organizations and teams so rules can match "acme/eng". client is nil outside a test that answers for github.com.

func GoogleSignIn

func GoogleSignIn(clientID, secret, externalURL string, client *http.Client) SignInProvider

GoogleSignIn signs people in with Google. Google says nothing about groups without its admin API, so rules for these people match on email or domain.

Jump to

Keyboard shortcuts

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