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 /ui/bootstrap.json what the login screen needs,
before anyone is authenticated
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
GET /api/v1/capabilities what this build is and grants
GET / the web UI, and the fallback
for its client-side routes
GET /assets/{path...} the UI's hashed build output
The last two serve whatever the caller passed as Options.UI, and this package neither embeds nor imports it — see package web.
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 Capability ¶ added in v1.4.0
type Capability string
Capability names one question this build's reconciler can answer.
It is deliberately not a feature.Name, and the two must not be merged. A feature is what a *licence* grants, reported by a Reporter the licensed module supplies; a capability is what this build's reconciler is wired to do, which is a fact about the process rather than about an entitlement. D12 names four licensed capabilities across five flags and neither of these is among them, so putting them in feature.All() would have made the licence document answer a question about wiring.
It would also have had nowhere honest to answer from. The reconciler that serves the node roster is the Apache-2.0 one, so the *community* reporter would have had to report a fact it has no view of — and the moment a build wired the seam differently, the flag and the endpoint would disagree with nothing to notice it.
A string type rather than an enumeration, for the reason authz.Action and feature.Name give: the list grows, and a client written against it must keep working when it does.
const ( // CapabilityLogs is tailing a service's container output — // GET /api/v1/applications/{app}/services/{svc}/logs, served by LogStreamer. CapabilityLogs Capability = "logs" // CapabilityNodes is describing the swarm's nodes — GET /api/v1/nodes, // served by NodeLister. CapabilityNodes Capability = "nodes" )
func Capabilities ¶ added in v1.4.0
func Capabilities() []Capability
Capabilities names every capability the document reports, in the order it should list them.
It exists for the reason feature.All does: the key set of the document is decided here rather than by whatever computed the values. A UI hiding a control on capabilities["logs"] has to tell false from absent, and a document whose keys moved would make a dropped key read exactly like a capability that is off — the control would vanish rather than stay hidden for a stated reason.
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 LogStreamer ¶ added in v1.4.0
type LogStreamer interface {
ServiceLogs(ctx context.Context, app, svc string, req application.ServiceLogRequest) (<-chan application.ServiceLogEvent, error)
}
LogStreamer is the optional interface a Reconciler implements when it can stream live Docker Swarm service container logs.
Optional in the seam sense: a build whose reconciler does not implement it is expected rather than broken, and says so with a 501.
What an implementer owes the caller ¶
Events, in order, on a channel the implementer owns and closes exactly once. Cancelling ctx is how the caller stops it, so there is no Close to forget. `Stream` is "stdout" or "stderr" — a line whose origin is not known is stdout, which is the honest default, and must never be reported as stderr. `Timestamp` is when the *container* emitted the line rather than when it was read: a hundred lines of backlog stamped with the read time is a hundred things happening at once, which in an incident is a lie about the sequence. `Message` carries no trailing newline.
It must not block on a caller that has stopped reading. A container in a crash loop outruns a browser, and the answer is to drop and say so with application.ServiceLogEvent.Notice, not to buffer the difference.
This used to hand back an io.ReadCloser of lines with an optional "stderr\t" prefix, which the handler split and parsed. It carries events instead because the wire type has two fields that shape could not reach: the daemon states which task and which node each line came from, and a byte stream has nowhere to put them — so for a replicated service, one node crash-looping and the whole service being broken looked identical.
`app` and `svc` have already been authorised and, more importantly, `svc` has already been checked to be one of `app`'s own services — see serviceLogs. An implementer must not treat `svc` as a swarm-wide service name it may look up on its own, because the guard authorised the subject for `app` and for nothing else.
type NodeLister ¶ added in v1.4.0
type NodeLister interface {
Nodes(ctx context.Context) (application.NodesResponse, error)
}
NodeLister is the optional interface a Reconciler implements when it can inspect the swarm's physical and virtual nodes.
Optional in the seam sense: *reconcile.Reconciler implements it, so this build answers, but a reconciler reached through the same interface that cannot is expected rather than broken. What such a build must not do is answer anyway — see nodes below.
Implementing the method is not the same as being able to answer. Which backend serves a destination is settled per request, so a reconciler that has this method may still meet a backend with no roster behind it; it reports that with application.ErrUnsupported, and nodes answers it identically to a reconciler that never had the method.
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
// UI is what the two public UI routes serve; web.Handler is what production
// passes. Absent — a build run with --ui=false, and every test that
// constructs a zero Options — they answer 404, because the routes are
// registered either way and only the response differs. New defaults it for
// the same reason Handler refuses a nil extension handler: the alternative
// is a panic on the first request that reaches it.
UI http.Handler
// Version is the build's own version, reported by the capability document.
// The controller passes the string goreleaser and the Dockerfile stamp.
// Absent, the document reports an empty string rather than inventing a
// number: a caller reading "" knows nobody stamped one, and a caller
// reading "unknown" has to be told what that means.
Version string
// Features reports what this build grants, for the capability document.
// Absent, the seam in force answers — which is the only thing production
// ever wants, and is a field only so that a test can install a report
// without registering one process-wide.
//
// Read once here because the seam is a Slot and a Slot is settled by the
// end of init(). What settles is who answers: Report is still called per
// request, so a licence that lapses stops being reported without a restart.
Features feature.Reporter
}
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.