Documentation
¶
Overview ¶
Package api serves the controller's HTTP interface.
Per D1 it is designed UI-first: the API comes first, a TUI view second and a web UI third, and it has to already be the shape those need. That is a constraint on the endpoint set rather than a slogan — every screen a UI has is one request, and every action a user can take is one endpoint:
GET /healthz unauthenticated liveness
GET /api/v1/status the controller's own state
GET /api/v1/applications the list view
GET /api/v1/applications/{app} the detail view
GET /api/v1/applications/{app}/diff the diff view
GET /api/v1/applications/{app}/history the history view
POST /api/v1/applications/{app}/sync the sync button
GET /api/v1/events live updates, so nothing polls
Applications are read-only: they are declared in the app set, which is either mounted at deploy time or committed to git, and changing them means changing that file rather than posting to this API. The paths are nouns so that CRUD can be added later without any of them moving.
That set is the core's. A companion module adds routes of its own through the extension seam, and everything Handler registers — core route or companion's — is served behind guard, with the authz.Action the route declares, unless the companion declared it public on purpose. See docs/extensibility.md.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Controller ¶
type Controller interface {
Status() application.ControllerStatus
}
Controller reports the controller's own state, as distinct from the applications'. *appset.Loop implements it.
type Options ¶
type Options struct {
Authorizer authz.Authorizer
Log *slog.Logger
// Controller reports where the app set came from and whether it is loading.
// Absent, the status endpoint still answers — with the application count and
// an empty app-set mode, which is what "no app-set source is wired" looks
// like. A status endpoint that 404s is a status endpoint a monitor cannot
// tell from a dead controller.
Controller Controller
}
Options tune a Server. Every field has a working default.
type Reconciler ¶
type Reconciler interface {
Views() []application.View
View(app string) (application.View, bool)
Diffs(app string) ([]application.ReleaseDiff, error)
History(ctx context.Context, app string) (application.History, error)
// AcceptSync rather than SyncNow: the handler has to know whether a sync was
// started before it writes the response, and the sync itself outlives the
// request. See sync below.
AcceptSync(app string) (func(context.Context) error, error)
}
Reconciler is what the API serves. *reconcile.Reconciler implements it.
This package does not import reconcile, and the two sentinels the handlers below match on live in application for that reason. An alternative reconciler is the whole point of stating this as an interface, and one that had to import the OSS applier — go-git, the chart engine, the moby client — for two error values would have had no way to take it up.
type RegisteredRoute ¶
type RegisteredRoute struct {
Pattern string // "GET /api/v1/projects"
Extension string // the name it registered under; empty for a core route
Public bool // served with no authentication
}
RegisteredRoute is one route the server serves, and who put it there.
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server is the HTTP interface. It is also a notify.Notifier: the event stream is fed by the same seam that feeds the log, which is why notify appends rather than replaces — a companion adding Slack must not silently kill the UI's live updates.
func New ¶
func New(rec Reconciler, o Options) *Server
New returns a Server over rec.
It does not register itself as a notifier. The caller does that, so that the notifier list is not appended to as a side effect of constructing a server — which in a test suite means one stream per test, all still subscribed.
func (*Server) Drain ¶
func (s *Server) Drain()
Drain ends every connected event stream. The caller registers it with http.Server.RegisterOnShutdown; see stream.closeAll for why Shutdown cannot do it on its own.
func (*Server) Handler ¶
Handler returns the router.
This is where the extension seam is read, and it is read exactly once. A seam.List is not settled at the end of init() and a consumer may not snapshot it at construction — but nothing joins this one after the mux exists, so the moment the mux is built is the moment the answer stops changing, and reading it here is the consumer-side half of that rule rather than an exception to it.
The error is a refusal to start. Everything a companion declared is checked before a single route reaches the mux, because both alternatives reach an operator as an outage rather than as a controller that would not start: net/http panics on a duplicate pattern, deep inside wiring, and a nil handler would panic on the first request that hit it.
func (*Server) Notify ¶
Notify feeds the event stream. It is the notify.Notifier implementation; the caller registers it.
func (*Server) Routes ¶
func (s *Server) Routes() []RegisteredRoute
Routes reports every route Handler registered — the core's first, then each extension's in registration order — and is meaningful only once Handler has succeeded, because until the seam has been read the set is not decided.
It exists for the startup log rather than for the router. An operator's only signal that a companion added an unauthenticated endpoint to a process holding the docker socket is a line naming the pattern and the module that added it, and a companion's source is not something the operator running the binary necessarily has.
The caller logs the guarded ones as a list and warns, one line each, on the public routes that carry an extension name. Not on a public core route: this repository argued for that one in its own source and no deployment can change it, and a WARN on every startup about something nobody can act on is how an operator learns to ignore the WARN that matters.