- 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>
634 lines
20 KiB
Go
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
|
|
}
|