- PATCH /agents/{id} switches the mode and reconnects the agent
- POST /revisions/{n}/deploy creates pending push deployments, sends
ApplyRevisionCommand with the deployment ID, re-sends open pushes on
connect and announces them in GetDesiredState; reports with the ID
complete the deployment
- POST /revisions/{n}/rollback re-renders the stored generator input
with the next revision number
- GET /revisions/{n}/diff and GET /config/pending with unified diffs
- GET /events streams agent, deployment, revision and pending events
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
211 lines
5.9 KiB
Go
211 lines
5.9 KiB
Go
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
|
|
|
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/httpserver"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/service"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/store"
|
|
)
|
|
|
|
// DefaultEventKeepalive is the interval of keepalive comments on the event
|
|
// stream; the session is checked at the same interval (ADR 0022).
|
|
const DefaultEventKeepalive = 25 * time.Second
|
|
|
|
// decodeOptional decodes an optional JSON body into dst.
|
|
func decodeOptional(w http.ResponseWriter, r *http.Request, dst any) error {
|
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
|
|
if err != nil {
|
|
return errBadRequest("body_too_large", "request body too large")
|
|
}
|
|
if len(bytes.TrimSpace(body)) == 0 {
|
|
return nil
|
|
}
|
|
r.Body = io.NopCloser(bytes.NewReader(body))
|
|
return decode(w, r, dst)
|
|
}
|
|
|
|
// revisionNumber parses the {number} path parameter; anything invalid is a
|
|
// revision that does not exist.
|
|
func revisionNumber(r *http.Request) (int64, error) {
|
|
n, err := strconv.ParseInt(r.PathValue("number"), 10, 64)
|
|
if err != nil || n < 1 || n > store.MaxRevision {
|
|
return 0, service.ErrNotFound
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
type agentUpdateRequest struct {
|
|
Mode string `json:"mode"`
|
|
}
|
|
|
|
func (a *API) updateAgent(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
|
|
var req agentUpdateRequest
|
|
if err := decode(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
ag, err := a.svc.SetAgentMode(r.Context(), rc.Actor, r.PathValue("id"), req.Mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, http.StatusOK, ag)
|
|
}
|
|
|
|
type deployRequest struct {
|
|
AgentIDs []string `json:"agent_ids"`
|
|
}
|
|
|
|
func (a *API) deployRevision(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
|
|
n, err := revisionNumber(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var req deployRequest
|
|
if err := decodeOptional(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
targets, err := a.svc.DeployRevision(r.Context(), rc.Actor, n, req.AgentIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, http.StatusOK, listResponse[service.DeployTarget]{Items: targets})
|
|
}
|
|
|
|
func (a *API) rollbackRevision(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
|
|
n, err := revisionNumber(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var req revisionRequest
|
|
if err := decodeOptional(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
rev, err := a.svc.RollbackRevision(r.Context(), rc.Actor, n, req.Comment)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, http.StatusCreated, rev)
|
|
}
|
|
|
|
func (a *API) diffRevision(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
|
|
n, err := revisionNumber(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var against *int64
|
|
if v := r.URL.Query().Get("against"); v != "" {
|
|
m, err := strconv.ParseInt(v, 10, 64)
|
|
if err != nil || m < 0 {
|
|
return errBadRequest("invalid_parameter", "invalid against")
|
|
}
|
|
against = &m
|
|
}
|
|
d, err := a.svc.DiffRevisions(r.Context(), n, against)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, http.StatusOK, d)
|
|
}
|
|
|
|
func (a *API) pendingChanges(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
|
|
p, err := a.svc.PendingChanges(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, http.StatusOK, p)
|
|
}
|
|
|
|
var errEventsUnavailable = &service.Error{Kind: service.KindUnavailable, Code: "events_unavailable",
|
|
Message: "live events are not available"}
|
|
|
|
// streamEvents serves the Server-Sent Events stream (ADR 0022) until the
|
|
// client goes away, the session ends or the server shuts down.
|
|
func (a *API) streamEvents(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
|
|
bus := a.svc.Events()
|
|
if bus == nil {
|
|
return errEventsUnavailable
|
|
}
|
|
ctl := http.NewResponseController(w)
|
|
// The server's read and write timeouts are meant for ordinary
|
|
// requests: lift the read deadline (it would cancel the request) and
|
|
// move the write deadline ahead before every write.
|
|
if err := ctl.SetReadDeadline(time.Time{}); err != nil && !errors.Is(err, http.ErrNotSupported) {
|
|
return err
|
|
}
|
|
keepalive := a.opts.EventKeepalive
|
|
if keepalive <= 0 {
|
|
keepalive = DefaultEventKeepalive
|
|
}
|
|
sub := bus.Subscribe(0)
|
|
defer sub.Close()
|
|
|
|
write := func(b []byte) error {
|
|
if err := ctl.SetWriteDeadline(time.Now().Add(keepalive + 30*time.Second)); err != nil && !errors.Is(err, http.ErrNotSupported) {
|
|
return err
|
|
}
|
|
if _, err := w.Write(b); err != nil {
|
|
return err
|
|
}
|
|
return ctl.Flush()
|
|
}
|
|
|
|
h := w.Header()
|
|
h.Set("Content-Type", "text/event-stream")
|
|
h.Set("Cache-Control", "no-store")
|
|
h.Set("X-Accel-Buffering", "no") // reverse proxies must not buffer
|
|
w.WriteHeader(http.StatusOK)
|
|
// The retry hint makes EventSource reconnect after 5 s.
|
|
if err := write([]byte("retry: 5000\n: connected\n\n")); err != nil {
|
|
return nil //nolint:nilerr // the client is gone; nothing to report
|
|
}
|
|
|
|
ticker := time.NewTicker(keepalive)
|
|
defer ticker.Stop()
|
|
expiry := time.NewTimer(time.Until(rc.Session.ExpiresAt))
|
|
defer expiry.Stop()
|
|
log := a.log.With(slog.String("request_id", httpserver.RequestIDFromContext(r.Context())))
|
|
for {
|
|
select {
|
|
case <-r.Context().Done():
|
|
return nil
|
|
case <-expiry.C:
|
|
return nil
|
|
case ev, ok := <-sub.C():
|
|
if !ok {
|
|
return nil // server shutdown
|
|
}
|
|
msg := make([]byte, 0, len(ev.Type)+len(ev.Data)+16)
|
|
msg = append(msg, "event: "...)
|
|
msg = append(msg, ev.Type...)
|
|
msg = append(msg, "\ndata: "...)
|
|
msg = append(msg, ev.Data...)
|
|
msg = append(msg, "\n\n"...)
|
|
if err := write(msg); err != nil {
|
|
return nil //nolint:nilerr // the client is gone
|
|
}
|
|
case <-ticker.C:
|
|
active, err := a.svc.SessionActive(r.Context(), rc.Session.TokenHash)
|
|
if err != nil && r.Context().Err() == nil {
|
|
log.Warn("event stream: session check failed", slog.Any("error", err))
|
|
}
|
|
if !active {
|
|
return nil
|
|
}
|
|
if err := write([]byte(": keepalive\n\n")); err != nil {
|
|
return nil //nolint:nilerr // the client is gone
|
|
}
|
|
if n := sub.Dropped(); n > 0 {
|
|
log.Debug("event stream: slow client dropped events", slog.Int64("dropped", n))
|
|
}
|
|
}
|
|
}
|
|
}
|