Files
DDI/server/internal/service/service.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

172 lines
5.2 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package service
import (
"context"
"encoding/json"
"log/slog"
"net/netip"
"slices"
"time"
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
"git.beugsiud.de/beug.k/DDI/server/internal/auth"
"git.beugsiud.de/beug.k/DDI/server/internal/events"
"git.beugsiud.de/beug.k/DDI/server/internal/license"
"git.beugsiud.de/beug.k/DDI/server/internal/pki"
"git.beugsiud.de/beug.k/DDI/server/internal/secrets"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
)
// Actor is the authenticated user performing an operation.
type Actor struct {
UserID string
Username string
Role auth.Role
IP *netip.Addr
RequestID string
// SessionHash identifies the current session (kept on password change).
SessionHash []byte
}
// SystemActor is used for operations without a user (CLI, background jobs).
var SystemActor = &Actor{Username: "system", Role: auth.RoleAdmin}
// Options configures a Service.
type Options struct {
Store *store.Store
Keyring *secrets.Keyring
License *license.Manager
ProductName string
// SessionLifetime and SessionIdle bound web sessions (ADR 0020).
SessionLifetime time.Duration
SessionIdle time.Duration
Now func() time.Time
Logger *slog.Logger
// Agent API (ADR 0021). Without PKI, enrollment tokens cannot be
// created and agents cannot enroll.
PKI *pki.Authority
// Hub tracks the agents' open streams (optional).
Hub *agenthub.Hub
// AgentAPIAddress is the host:port shown in the enrollment command.
AgentAPIAddress string
// AgentServerNames are the SANs of the agent-port certificate.
AgentServerNames []string
// Events receives change notifications for the live UI (optional).
Events *events.Bus
}
// Service implements the use cases.
type Service struct {
opts Options
st *store.Store
now func() time.Time
log *slog.Logger
ipLimiter *auth.Limiter
userLimiter *auth.Limiter
// enrollLimiter bounds enrollment attempts per source IP.
enrollLimiter *auth.Limiter
}
// New creates a Service.
func New(opts Options) *Service {
if opts.Now == nil {
opts.Now = time.Now
}
if opts.Logger == nil {
opts.Logger = slog.New(slog.DiscardHandler)
}
if opts.SessionLifetime <= 0 {
opts.SessionLifetime = 12 * time.Hour
}
if opts.SessionIdle <= 0 {
opts.SessionIdle = time.Hour
}
return &Service{
opts: opts, st: opts.Store, now: opts.Now, log: opts.Logger,
// ADR 0020: 5 attempts, then one per minute; per IP a little more
// generous because several users may share an address.
ipLimiter: auth.NewLimiter(20, 15*time.Second),
userLimiter: auth.NewLimiter(5, time.Minute),
// Enrollment: 10 attempts, then one every 30 s per source IP.
enrollLimiter: auth.NewLimiter(10, 30*time.Second),
}
}
// License returns the license manager.
func (s *Service) License() *license.Manager { return s.opts.License }
// Ping checks the database (readiness).
func (s *Service) Ping(ctx context.Context) error { return s.st.Ping(ctx) }
// requireWritable fails if the license does not allow changes. The HTTP layer
// checks this for every writing route as well; this is defense in depth.
func (s *Service) requireWritable() error {
if s.opts.License != nil && !s.opts.License.Writable() {
return ErrReadOnly
}
return nil
}
// audit writes an entry within tx. before/after are marshaled to JSON.
func (s *Service) audit(ctx context.Context, tx *store.Tx, a *Actor, action, objType, objID, objName string, before, after any) error {
e := &store.AuditEntry{ActorName: a.Username, Action: action, ObjectType: objType, ObjectName: objName,
RequestID: a.RequestID, IP: a.IP}
if a.UserID != "" {
id := a.UserID
e.ActorID = &id
}
if objID != "" {
e.ObjectID = &objID
}
var err error
if before != nil {
if e.Before, err = json.Marshal(before); err != nil {
return err
}
}
if after != nil {
if e.After, err = json.Marshal(after); err != nil {
return err
}
}
if err := tx.InsertAudit(ctx, e); err != nil {
return err
}
if affectsConfig(action, objType) {
tx.AfterCommit(events.TypePending, func() { s.publish(events.TypePending, events.Pending{}) })
}
return nil
}
// configObjectTypes and configActions identify audit entries of data the
// generator uses (pending changes, ADR 0022).
var (
configObjectTypes = []string{"network", "address"}
configActions = []string{"settings.generator"}
)
func affectsConfig(action, objType string) bool {
return slices.Contains(configObjectTypes, objType) || slices.Contains(configActions, action)
}
// Events returns the event bus (nil if not configured).
func (s *Service) Events() *events.Bus { return s.opts.Events }
// publish sends an event to the live UI.
func (s *Service) publish(typ string, data any) { s.opts.Events.Publish(typ, data) }
func (s *Service) publishAgent(id string) { s.publish(events.TypeAgent, events.Agent{ID: id}) }
func (s *Service) publishDeployment(d *store.Deployment) {
s.publish(events.TypeDeployment, events.Deployment{ID: d.ID, AgentID: d.AgentID, Revision: d.Revision})
}
// inTx runs fn in a transaction and translates errors.
func (s *Service) inTx(ctx context.Context, fn func(tx *store.Tx) error) error {
return translate(s.st.InTx(ctx, fn))
}