Files
DDI/server/internal/httpapi/h_deploy.go
T
beug.k@beugsiud.deandClaude Opus 5.5 00b9ffb65b feat(server): operating modes, push deployments, rollback, diff, pending changes and SSE
- 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>
2026-10-13 20:23:47 +02:00

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))
}
}
}
}