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

634 lines
20 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package service
import (
"cmp"
"context"
"encoding/json"
"errors"
"slices"
"strconv"
"strings"
"time"
"unicode/utf8"
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
"git.beugsiud.de/beug.k/DDI/server/internal/events"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
"git.beugsiud.de/beug.k/DDI/server/internal/textdiff"
)
// Revisions, pushes, rollbacks, diffs and pending changes (ADR 0021, 0022).
// ErrRollbackUnavailable is returned for revisions without stored generator
// input (created before M4).
var ErrRollbackUnavailable = newError(KindConflict, "rollback_unavailable",
"the revision has no stored generator input and cannot be rolled back to")
// diffContext is the number of context lines in revision diffs.
const diffContext = 3
// maxPendingChanges bounds the audit entries listed as pending changes.
const maxPendingChanges = 200
func validRevisionNumber(n int64) bool { return n >= 1 && n <= store.MaxRevision }
// revisionAudit is the audit snapshot of a new revision.
type revisionAudit struct {
RevisionSummaryDTO
RollbackOf int64 `json:"rollback_of,omitempty"`
}
// storeRevision stores the output g of src (rendered with src.Serial as
// revision number) and supersedes pending pushes of agents in auto_pull mode
// (the new revision replaces them). It runs in tx after LockRevisions.
func (s *Service) storeRevision(ctx context.Context, tx *store.Tx, a *Actor, src configgen.Source, g *Generated,
comment string, rollbackOf int64,
) (RevisionDTO, []*store.Deployment, error) {
warnings, err := json.Marshal(g.Warnings)
if err != nil {
return RevisionDTO{}, nil, err
}
source, err := json.Marshal(src)
if err != nil {
return RevisionDTO{}, nil, err
}
now := s.now().UTC().Truncate(time.Microsecond)
r := &store.Revision{Number: int64(src.Serial), CreatedAt: now, CreatedByName: a.Username, Comment: comment,
Warnings: warnings, Source: source}
if a.UserID != "" {
uid := a.UserID
r.CreatedBy = &uid
}
files := revisionFiles(g)
if err := tx.InsertRevision(ctx, r, files); err != nil {
return RevisionDTO{}, nil, err
}
superseded, err := tx.SupersedeDeployments(ctx, store.SupersedeFilter{AutoPullOnly: true, KeepRevision: r.Number}, now)
if err != nil {
return RevisionDTO{}, nil, err
}
summary := revisionAudit{RevisionSummaryDTO: RevisionSummaryDTO{Number: r.Number, CreatedAt: r.CreatedAt,
CreatedByName: r.CreatedByName, Comment: r.Comment, DNSFiles: len(g.DNS), DHCPFiles: len(g.DHCP),
Warnings: len(g.Warnings)}, RollbackOf: rollbackOf}
action := "revision.create"
if rollbackOf > 0 {
action = "revision.rollback"
}
num := strconv.FormatInt(r.Number, 10)
if err := s.audit(ctx, tx, a, action, "revision", num, "#"+num, nil, summary); err != nil {
return RevisionDTO{}, nil, err
}
return revisionDTO(r, files), superseded, nil
}
// revisionCreated notifies agents and the UI after a new revision was
// committed.
func (s *Service) revisionCreated(a *Actor, rev *RevisionDTO, superseded []*store.Deployment) {
notified := 0
if s.opts.Hub != nil {
notified = s.opts.Hub.NotifyRevision(uint64(rev.Number)) //nolint:gosec // positive
}
s.log.Info("revision created", "revision", rev.Number, "notified_agents", notified,
"superseded_pushes", len(superseded), "request_id", a.RequestID)
for _, d := range superseded {
s.publishDeployment(d)
}
s.publish(events.TypeRevision, events.Revision{Number: rev.Number})
s.publish(events.TypePending, events.Pending{})
}
func checkComment(comment string) (string, error) {
comment = strings.TrimSpace(comment)
if utf8.RuneCountInString(comment) > 500 {
return "", invalidField("too_long", "comment")
}
return comment, nil
}
// CreateRevision generates the configuration and stores it as a new
// revision (ADR 0021). If the output equals the latest revision, that one
// is returned with created false. Agents in auto_pull mode are notified.
func (s *Service) CreateRevision(ctx context.Context, a *Actor, comment string) (*RevisionDTO, bool, error) {
if err := s.requireWritable(); err != nil {
return nil, false, err
}
comment, err := checkComment(comment)
if err != nil {
return nil, false, err
}
var out RevisionDTO
var superseded []*store.Deployment
created := false
err = s.inTx(ctx, func(tx *store.Tx) error {
if err := tx.LockRevisions(ctx); err != nil {
return err
}
latest, err := tx.LatestRevision(ctx)
if err != nil && !errors.Is(err, store.ErrNotFound) {
return err
}
src, err := s.loadSource(ctx, 1)
if err != nil {
return err
}
next := int64(1)
if latest != nil {
// Generate with the latest number first: identical output
// means nothing changed since then.
src.Serial = uint32(latest.Number) //nolint:gosec // 1..2^32-1 by constraint
g := render(src)
if err := generationProblem(g); err != nil {
return err
}
stored, err := tx.RevisionFiles(ctx, latest.Number, "")
if err != nil {
return err
}
if sameFiles(revisionFiles(g), stored) {
out = revisionDTO(latest, stored)
return nil
}
if latest.Number >= store.MaxRevision {
return ErrRevisionLimit
}
next = latest.Number + 1
}
src.Serial = uint32(next) //nolint:gosec // 1..2^32-1, checked above
g := render(src)
if err := generationProblem(g); err != nil {
return err
}
out, superseded, err = s.storeRevision(ctx, tx, a, src, g, comment, 0)
created = err == nil
return err
})
if err != nil {
return nil, false, err
}
if created {
s.revisionCreated(a, &out, superseded)
}
return &out, created, nil
}
// RollbackRevision creates a new revision with the content of revision n:
// n's generator input is rendered again with the next number, so that SOA
// serials keep increasing (ADR 0009, 0022). The result is stored even if it
// equals the latest revision.
func (s *Service) RollbackRevision(ctx context.Context, a *Actor, n int64, comment string) (*RevisionDTO, error) {
if err := s.requireWritable(); err != nil {
return nil, err
}
comment, err := checkComment(comment)
if err != nil {
return nil, err
}
if !validRevisionNumber(n) {
return nil, ErrNotFound
}
if comment == "" {
comment = "Rollback to revision " + strconv.FormatInt(n, 10)
}
var out RevisionDTO
var superseded []*store.Deployment
err = s.inTx(ctx, func(tx *store.Tx) error {
if err := tx.LockRevisions(ctx); err != nil {
return err
}
raw, err := tx.RevisionSource(ctx, n)
if err != nil {
return err
}
if raw == nil {
return ErrRollbackUnavailable
}
var src configgen.Source
if err := json.Unmarshal(raw, &src); err != nil {
e := *ErrRollbackUnavailable
e.err = err
return &e
}
latest, err := tx.LatestRevision(ctx)
if err != nil {
return err
}
if latest.Number >= store.MaxRevision {
return ErrRevisionLimit
}
src.Serial = uint32(latest.Number + 1) //nolint:gosec // checked above
g := render(src)
if err := generationProblem(g); err != nil {
return err
}
out, superseded, err = s.storeRevision(ctx, tx, a, src, g, comment, n)
return err
})
if err != nil {
return nil, err
}
s.revisionCreated(a, &out, superseded)
return &out, nil
}
// FileDiff is the change of one generated file.
type FileDiff struct {
Role string `json:"role"`
Path string `json:"path"`
Status string `json:"status"` // added, removed, modified
Diff string `json:"diff"`
Inserted int `json:"inserted"`
Deleted int `json:"deleted"`
}
// RevisionDiff lists the files that differ between two revisions.
type RevisionDiff struct {
Number int64 `json:"number"`
Against int64 `json:"against"`
Files []FileDiff `json:"files"`
}
func roleOrder(role string) int {
if role == RoleDNS {
return 0
}
return 1
}
// diffFiles compares two file sets; unchanged files are left out. The
// result is ordered by role (DNS first) and path.
func diffFiles(older, newer []store.RevisionFile) []FileDiff {
type key struct{ role, path string }
oldBy := make(map[key]store.RevisionFile, len(older))
for _, f := range older {
oldBy[key{f.Role, f.Path}] = f
}
newBy := make(map[key]store.RevisionFile, len(newer))
keys := make([]key, 0, len(older)+len(newer))
for _, f := range newer {
k := key{f.Role, f.Path}
newBy[k] = f
keys = append(keys, k)
}
for _, f := range older {
if k := (key{f.Role, f.Path}); !hasKey(newBy, k) {
keys = append(keys, k)
}
}
slices.SortFunc(keys, func(a, b key) int {
return cmp.Or(cmp.Compare(roleOrder(a.role), roleOrder(b.role)), strings.Compare(a.path, b.path))
})
keys = slices.Compact(keys)
out := []FileDiff{}
for _, k := range keys {
o, inOld := oldBy[k]
n, inNew := newBy[k]
d := FileDiff{Role: k.role, Path: k.path}
switch {
case !inOld:
d.Status = "added"
case !inNew:
d.Status = "removed"
case string(o.Content) == string(n.Content) && o.Mode == n.Mode && o.CreateOnly == n.CreateOnly:
continue
default:
d.Status = "modified"
}
a, b := string(o.Content), string(n.Content)
d.Diff = textdiff.Unified(a, b, diffContext)
d.Inserted, d.Deleted = textdiff.Stats(textdiff.Lines(a, b))
out = append(out, d)
}
return out
}
func hasKey[K comparable, V any](m map[K]V, k K) bool {
_, ok := m[k]
return ok
}
// DiffRevisions compares revision n with revision against (nil: n-1; 0:
// nothing, every file is added).
func (s *Service) DiffRevisions(ctx context.Context, n int64, against *int64) (*RevisionDiff, error) {
if !validRevisionNumber(n) {
return nil, ErrNotFound
}
m := n - 1
if against != nil {
m = *against
}
if m < 0 || m > store.MaxRevision {
return nil, ErrNotFound
}
q := s.st.Q()
if _, err := q.GetRevision(ctx, n); err != nil {
return nil, translate(err)
}
newer, err := q.RevisionFiles(ctx, n, "")
if err != nil {
return nil, translate(err)
}
var older []store.RevisionFile
if m > 0 {
if _, err := q.GetRevision(ctx, m); err != nil {
return nil, translate(err)
}
if older, err = q.RevisionFiles(ctx, m, ""); err != nil {
return nil, translate(err)
}
}
return &RevisionDiff{Number: n, Against: m, Files: diffFiles(older, newer)}, nil
}
// PendingChanges are the changes since the latest revision.
type PendingChanges struct {
LatestRevision int64 `json:"latest_revision"`
Changed bool `json:"changed"`
Files []FileDiff `json:"files"`
Changes []*store.AuditEntry `json:"changes"`
DNSError *GeneratorProblem `json:"dns_error,omitempty"`
DHCPError *GeneratorProblem `json:"dhcp_error,omitempty"`
}
func withoutRole(files []store.RevisionFile, role string) []store.RevisionFile {
return slices.DeleteFunc(files, func(f store.RevisionFile) bool { return f.Role == role })
}
// PendingChanges generates the configuration from the current data with the
// latest revision's number (so the SOA serial does not count as a change)
// and compares it with that revision. Parts that cannot be generated are
// reported in DNSError/DHCPError and not compared. Changes lists the audit
// entries of generator data since the latest revision, newest first.
func (s *Service) PendingChanges(ctx context.Context) (*PendingChanges, error) {
q := s.st.Q()
latest, err := q.LatestRevision(ctx)
if err != nil && !errors.Is(err, store.ErrNotFound) {
return nil, translate(err)
}
serial := uint32(1)
out := &PendingChanges{}
var stored []store.RevisionFile
filter := store.ChangeFilter{ObjectTypes: configObjectTypes, Actions: configActions, Limit: maxPendingChanges}
if latest != nil {
serial = uint32(latest.Number) //nolint:gosec // 1..2^32-1 by constraint
out.LatestRevision = latest.Number
filter.AfterRevision, filter.Since = latest.Number, latest.CreatedAt
if stored, err = q.RevisionFiles(ctx, latest.Number, ""); err != nil {
return nil, translate(err)
}
}
src, err := s.loadSource(ctx, serial)
if err != nil {
return nil, err
}
g := render(src)
out.DNSError, out.DHCPError = g.DNSError, g.DHCPError
generated := revisionFiles(g)
if g.DNSError != nil {
stored = withoutRole(stored, RoleDNS)
}
if g.DHCPError != nil {
stored = withoutRole(stored, RoleDHCP)
}
out.Files = diffFiles(stored, generated)
out.Changed = len(out.Files) > 0
changes, err := q.ConfigChangesSince(ctx, filter)
if err != nil {
return nil, translate(err)
}
out.Changes = make([]*store.AuditEntry, len(changes))
for i, c := range changes {
c.IP = nil // the source address of other users is for the audit log only
out.Changes[i] = c
}
return out, nil
}
// DeployTarget is one agent a push was created for.
type DeployTarget struct {
AgentID string `json:"agent_id"`
AgentHostname string `json:"agent_hostname"`
DeploymentID int64 `json:"deployment_id"`
Sent bool `json:"sent"`
}
// deployAudit is the audit snapshot of a push.
type deployAudit struct {
Revision int64 `json:"revision"`
AgentIDs []string `json:"agent_ids"`
DeploymentIDs []int64 `json:"deployment_ids"`
}
// DeployRevision pushes revision n to the given agents (all agents that are
// not revoked if agentIDs is empty), ADR 0022: one pending deployment per
// agent, older pending pushes of these agents are superseded, agents in
// manual mode get n as target revision, connected agents receive an
// ApplyRevisionCommand at once. Revisions older than the latest cannot be
// pushed to agents in auto_pull mode (rollback_required).
func (s *Service) DeployRevision(ctx context.Context, a *Actor, n int64, agentIDs []string) ([]DeployTarget, error) {
if err := s.requireWritable(); err != nil {
return nil, err
}
if !validRevisionNumber(n) {
return nil, ErrNotFound
}
var created, superseded []*store.Deployment
err := s.inTx(ctx, func(tx *store.Tx) error {
// Serialize with revision creation: "latest" must not change
// while auto_pull agents are checked.
if err := tx.LockRevisions(ctx); err != nil {
return err
}
if _, err := tx.GetRevision(ctx, n); err != nil {
return err
}
latest, err := latestRevisionNumber(ctx, tx)
if err != nil {
return err
}
agents, err := s.deployTargets(ctx, tx, agentIDs)
if err != nil {
return err
}
var rollback []Problem
for _, ag := range agents {
// Agents in auto_pull mode always return to the latest
// revision, and no agent accepts a revision older than the
// one it runs (replay protection, ADR 0009); re-applying the
// running revision is allowed.
if (ag.Mode == agenthub.ModeAutoPull && n < latest) || ag.AppliedRevision > n {
rollback = append(rollback, Problem{Code: "rollback_required", Field: "agent_ids",
Params: map[string]string{"agent_id": ag.ID, "hostname": ag.Hostname, "mode": ag.Mode,
"applied_revision": strconv.FormatInt(ag.AppliedRevision, 10)}})
}
}
if len(rollback) > 0 {
return &Error{Kind: KindInvalid, Code: "rollback_required",
Message: "agents cannot go back to an older revision by a push; use a rollback",
Params: map[string]string{"latest": strconv.FormatInt(latest, 10)},
Problems: rollback}
}
now := s.now().UTC().Truncate(time.Microsecond)
requester := a.Username
audit := deployAudit{Revision: n, AgentIDs: []string{}, DeploymentIDs: []int64{}}
for _, ag := range agents {
sup, err := tx.SupersedeDeployments(ctx, store.SupersedeFilter{AgentID: ag.ID}, now)
if err != nil {
return err
}
superseded = append(superseded, sup...)
d := &store.Deployment{AgentID: ag.ID, AgentHostname: ag.Hostname, Revision: n, StartedAt: now,
RequestedByName: &requester}
if a.UserID != "" {
uid := a.UserID
d.RequestedBy = &uid
}
if err := tx.InsertPushDeployment(ctx, d); err != nil {
return err
}
if ag.Mode == agenthub.ModeManual && ag.TargetRevision != n {
if err := tx.SetAgentTarget(ctx, ag.ID, n); err != nil {
return err
}
}
created = append(created, d)
audit.AgentIDs = append(audit.AgentIDs, ag.ID)
audit.DeploymentIDs = append(audit.DeploymentIDs, d.ID)
}
num := strconv.FormatInt(n, 10)
return s.audit(ctx, tx, a, "revision.deploy", "revision", num, "#"+num, nil, audit)
})
if err != nil {
return nil, err
}
out := make([]DeployTarget, 0, len(created))
for _, d := range created {
t := DeployTarget{AgentID: d.AgentID, AgentHostname: d.AgentHostname, DeploymentID: d.ID}
if s.opts.Hub != nil {
t.Sent = s.opts.Hub.Send(d.AgentID, agenthub.ApplyCommand(uint64(n), strconv.FormatInt(d.ID, 10))) //nolint:gosec // positive
}
out = append(out, t)
}
for _, d := range superseded {
s.publishDeployment(d)
}
for _, d := range created {
s.publishDeployment(d)
s.publishAgent(d.AgentID)
}
s.log.Info("revision pushed", "revision", n, "agents", len(created), "request_id", a.RequestID)
return out, nil
}
// deployTargets resolves and locks the agents of a push.
func (s *Service) deployTargets(ctx context.Context, tx *store.Tx, ids []string) ([]*store.Agent, error) {
if len(ids) == 0 {
all, err := tx.ListAgents(ctx)
if err != nil {
return nil, err
}
return slices.DeleteFunc(all, func(ag *store.Agent) bool { return ag.RevokedAt != nil }), nil
}
var out []*store.Agent
var problems []Problem
seen := map[string]bool{}
for i, id := range ids {
params := map[string]string{"agent_id": id, "index": strconv.Itoa(i)}
if seen[id] {
continue
}
seen[id] = true
if !ValidUUID(id) {
problems = append(problems, Problem{Code: "unknown_agent", Field: "agent_ids", Params: params})
continue
}
ag, err := tx.GetAgent(ctx, id, true)
switch {
case errors.Is(err, store.ErrNotFound):
problems = append(problems, Problem{Code: "unknown_agent", Field: "agent_ids", Params: params})
case err != nil:
return nil, err
case ag.RevokedAt != nil:
problems = append(problems, Problem{Code: "agent_revoked", Field: "agent_ids", Params: params})
default:
out = append(out, ag)
}
}
if len(problems) > 0 {
return nil, invalid(problems...)
}
return out, nil
}
// SetAgentMode changes the operating mode of an agent (ADR 0022). Switching
// to manual keeps the agent on the latest revision (it becomes the target);
// switching to auto_pull supersedes pending pushes of older revisions. A
// connected agent is disconnected so that it reconnects and receives the
// new mode in Welcome.
func (s *Service) SetAgentMode(ctx context.Context, a *Actor, id, mode string) (AgentDTO, error) {
if err := s.requireWritable(); err != nil {
return AgentDTO{}, err
}
if !ValidUUID(id) {
return AgentDTO{}, ErrNotFound
}
if mode != agenthub.ModeAutoPull && mode != agenthub.ModeManual {
return AgentDTO{}, invalidField("invalid_value", "mode")
}
var out AgentDTO
var superseded []*store.Deployment
changed := false
err := s.inTx(ctx, func(tx *store.Tx) error {
before, err := tx.GetAgent(ctx, id, true)
if err != nil {
return err
}
if before.Mode != mode {
latest, err := latestRevisionNumber(ctx, tx)
if err != nil {
return err
}
var target *int64
if mode == agenthub.ModeManual {
target = &latest
}
if _, err := tx.SetAgentMode(ctx, id, mode, target); err != nil {
return err
}
if mode == agenthub.ModeAutoPull {
superseded, err = tx.SupersedeDeployments(ctx, store.SupersedeFilter{AgentID: id, KeepRevision: latest},
s.now().UTC().Truncate(time.Microsecond))
if err != nil {
return err
}
}
changed = true
}
if out, err = s.loadAgentDTO(ctx, tx, id); err != nil {
return err
}
if !changed {
return nil
}
return s.audit(ctx, tx, a, "agent.update", "agent", id, before.Hostname, s.agentDTO(before, 0, nil), out)
})
if err != nil {
return AgentDTO{}, err
}
if changed {
if s.opts.Hub != nil {
s.opts.Hub.Disconnect(id, agenthub.ReasonModeChanged)
out.Connected = false
}
for _, d := range superseded {
s.publishDeployment(d)
}
s.publishAgent(id)
}
return out, nil
}