Agents get a target revision (manual mode), revisions store their generator input (jsonb) for rollbacks, deployments gain the outcomes pending and superseded, a nullable end and the requesting user. The duplicate protection now covers agent reports only. Transactions can register hooks that run after the commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
260 lines
9.7 KiB
Go
260 lines
9.7 KiB
Go
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
|
|
|
package store_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/store"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/store/storetest"
|
|
)
|
|
|
|
func ptr[T any](v T) *T { return &v }
|
|
|
|
func TestAgentModeAndTarget(t *testing.T) {
|
|
st := storetest.New(t)
|
|
ctx := context.Background()
|
|
a := newAgent(t, st, agentA, "dns", "ns1", "0a")
|
|
if a.TargetRevision != 0 || a.Mode != "auto_pull" {
|
|
t.Fatalf("new agent %+v", a)
|
|
}
|
|
got, err := st.Q().SetAgentMode(ctx, agentA, "manual", ptr(int64(4)))
|
|
if err != nil || got.Mode != "manual" || got.TargetRevision != 4 {
|
|
t.Fatalf("set mode %+v %v", got, err)
|
|
}
|
|
if got, err = st.Q().SetAgentMode(ctx, agentA, "auto_pull", nil); err != nil || got.TargetRevision != 4 {
|
|
t.Fatalf("mode without target %+v %v", got, err)
|
|
}
|
|
if err := st.Q().SetAgentTarget(ctx, agentA, 9); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := st.Q().GetAgent(ctx, agentA, false); got.TargetRevision != 9 {
|
|
t.Errorf("target %d", got.TargetRevision)
|
|
}
|
|
if err := st.Q().SetAgentTarget(ctx, agentA, 1<<32); !store.IsConstraint(err, "agents_target_revision") {
|
|
t.Errorf("target out of range: %v", err)
|
|
}
|
|
if err := st.Q().SetAgentTarget(ctx, agentB, 1); !errors.Is(err, store.ErrNotFound) {
|
|
t.Errorf("unknown agent: %v", err)
|
|
}
|
|
if _, err := st.Q().SetAgentMode(ctx, agentB, "manual", nil); !errors.Is(err, store.ErrNotFound) {
|
|
t.Errorf("mode of unknown agent: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRevisionSource(t *testing.T) {
|
|
st := storetest.New(t)
|
|
ctx := context.Background()
|
|
now := time.Now().UTC().Truncate(time.Microsecond)
|
|
src := json.RawMessage(`{"Serial":1,"SubnetIDs":{"a":7},"Networks":null}`)
|
|
if err := st.Q().InsertRevision(ctx, &store.Revision{Number: 1, CreatedAt: now, CreatedByName: "admin", Source: src}, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.Q().InsertRevision(ctx, &store.Revision{Number: 2, CreatedAt: now, CreatedByName: "admin"}, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := st.Q().RevisionSource(ctx, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var a, b map[string]any
|
|
if json.Unmarshal(got, &a) != nil || json.Unmarshal(src, &b) != nil || len(a) != 3 || a["SubnetIDs"].(map[string]any)["a"] != b["SubnetIDs"].(map[string]any)["a"] {
|
|
t.Errorf("source %s", got)
|
|
}
|
|
if got, err := st.Q().RevisionSource(ctx, 2); err != nil || got != nil {
|
|
t.Errorf("revision without source: %s %v", got, err)
|
|
}
|
|
if _, err := st.Q().RevisionSource(ctx, 3); !errors.Is(err, store.ErrNotFound) {
|
|
t.Errorf("unknown revision: %v", err)
|
|
}
|
|
if err := st.Q().InsertRevision(ctx, &store.Revision{Number: 3, CreatedAt: now, CreatedByName: "admin",
|
|
Source: json.RawMessage(`[1]`)}, nil); !store.IsConstraint(err, "config_revisions_source_object") {
|
|
t.Errorf("array source: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPushDeployments(t *testing.T) {
|
|
st := storetest.New(t)
|
|
ctx := context.Background()
|
|
q := st.Q()
|
|
now := time.Now().UTC().Truncate(time.Microsecond)
|
|
newAgent(t, st, agentA, "dns", "ns1", "0a")
|
|
newAgent(t, st, agentB, "dhcp", "dhcp1", "0b")
|
|
if _, err := q.SetAgentMode(ctx, agentB, "manual", nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
push := func(agent string, rev int64) *store.Deployment {
|
|
t.Helper()
|
|
d := &store.Deployment{AgentID: agent, Revision: rev, StartedAt: now, RequestedByName: ptr("admin")}
|
|
if err := q.InsertPushDeployment(ctx, d); err != nil {
|
|
t.Fatalf("push: %v", err)
|
|
}
|
|
if d.ID == 0 || d.Outcome != store.OutcomePending || d.Trigger != "push" {
|
|
t.Fatalf("pushed %+v", d)
|
|
}
|
|
return d
|
|
}
|
|
if _, err := q.PendingDeployment(ctx, agentA); !errors.Is(err, store.ErrNotFound) {
|
|
t.Fatalf("pending of none: %v", err)
|
|
}
|
|
// Same agent, revision and time: pushes are not subject to the
|
|
// duplicate protection of reports.
|
|
p1 := push(agentA, 1)
|
|
p2 := push(agentA, 1)
|
|
pb := push(agentB, 1)
|
|
got, err := q.PendingDeployment(ctx, agentA)
|
|
if err != nil || got.ID != p2.ID || got.FinishedAt != nil || got.RequestedByName == nil || *got.RequestedByName != "admin" ||
|
|
got.AgentHostname != "ns1" {
|
|
t.Fatalf("pending %+v %v", got, err)
|
|
}
|
|
|
|
// Superseding: per agent, keeping a revision, auto_pull only.
|
|
sup, err := q.SupersedeDeployments(ctx, store.SupersedeFilter{AgentID: agentA, KeepRevision: 1}, now)
|
|
if err != nil || len(sup) != 0 {
|
|
t.Fatalf("keep revision: %+v %v", sup, err)
|
|
}
|
|
sup, err = q.SupersedeDeployments(ctx, store.SupersedeFilter{AutoPullOnly: true}, now.Add(time.Second))
|
|
if err != nil || len(sup) != 2 {
|
|
t.Fatalf("supersede auto_pull: %+v %v", sup, err)
|
|
}
|
|
for _, d := range sup {
|
|
if d.AgentID != agentA || d.Outcome != store.OutcomeSuperseded || d.FinishedAt == nil || !d.FinishedAt.Equal(now.Add(time.Second)) {
|
|
t.Errorf("superseded %+v", d)
|
|
}
|
|
}
|
|
if got, err := q.PendingDeployment(ctx, agentB); err != nil || got.ID != pb.ID {
|
|
t.Errorf("manual agent's push superseded: %+v %v", got, err)
|
|
}
|
|
|
|
// Completing a superseded push records the real outcome; a resent
|
|
// report changes nothing.
|
|
res := store.DeploymentResult{Outcome: "succeeded", Message: "ok", ActiveRevision: 1, StartedAt: now.Add(time.Second),
|
|
FinishedAt: now.Add(2 * time.Second)}
|
|
d, done, err := q.CompleteDeployment(ctx, p1.ID, agentA, 1, res)
|
|
if err != nil || !done || d.Outcome != "succeeded" || d.Message != "ok" || d.FinishedAt == nil || !d.StartedAt.Equal(res.StartedAt) {
|
|
t.Fatalf("complete %+v %v %v", d, done, err)
|
|
}
|
|
res.Outcome = "failed"
|
|
if d, done, err := q.CompleteDeployment(ctx, p1.ID, agentA, 1, res); err != nil || done || d.Outcome != "succeeded" {
|
|
t.Errorf("resent %+v %v %v", d, done, err)
|
|
}
|
|
for _, c := range []struct {
|
|
id int64
|
|
agent string
|
|
rev int64
|
|
}{{p2.ID, agentB, 1}, {p2.ID, agentA, 2}, {p2.ID + 100, agentA, 1}} {
|
|
if _, _, err := q.CompleteDeployment(ctx, c.id, c.agent, c.rev, res); !errors.Is(err, store.ErrNotFound) {
|
|
t.Errorf("complete %+v: %v", c, err)
|
|
}
|
|
}
|
|
// Reports cannot be completed by ID.
|
|
rep := &store.Deployment{AgentID: agentA, Revision: 1, Trigger: "pull", Outcome: "succeeded", StartedAt: now, FinishedAt: ptr(now)}
|
|
if ok, err := q.InsertDeployment(ctx, rep); err != nil || !ok {
|
|
t.Fatal(err)
|
|
}
|
|
if ok, err := q.InsertDeployment(ctx, &store.Deployment{AgentID: agentA, Revision: 1, Trigger: "pull", Outcome: "failed",
|
|
StartedAt: now, FinishedAt: ptr(now)}); err != nil || ok {
|
|
t.Errorf("duplicate report: %v %v", ok, err)
|
|
}
|
|
if _, _, err := q.CompleteDeployment(ctx, rep.ID, agentA, 1, res); !errors.Is(err, store.ErrNotFound) {
|
|
t.Errorf("complete a report: %v", err)
|
|
}
|
|
// A pending outcome needs an open end and vice versa.
|
|
if _, err := q.InsertDeployment(ctx, &store.Deployment{AgentID: agentA, Revision: 3, Trigger: "pull", Outcome: "succeeded",
|
|
StartedAt: now}); !store.IsConstraint(err, "deployments_finished") {
|
|
t.Errorf("finished outcome without end: %v", err)
|
|
}
|
|
|
|
list, err := q.ListDeployments(ctx, store.DeploymentFilter{AgentID: agentA})
|
|
if err != nil || len(list) != 3 || list[0].ID != rep.ID || list[1].Outcome != store.OutcomeSuperseded {
|
|
t.Errorf("list %+v %v", list, err)
|
|
}
|
|
latest, _ := q.LatestDeployments(ctx)
|
|
if latest[agentB] == nil || latest[agentB].Outcome != store.OutcomePending {
|
|
t.Errorf("latest %+v", latest)
|
|
}
|
|
}
|
|
|
|
func TestAfterCommit(t *testing.T) {
|
|
st := storetest.New(t)
|
|
ctx := context.Background()
|
|
var calls []string
|
|
err := st.InTx(ctx, func(tx *store.Tx) error {
|
|
tx.AfterCommit("k", func() { calls = append(calls, "k1") })
|
|
tx.AfterCommit("k", func() { calls = append(calls, "k2") })
|
|
tx.AfterCommit("", func() { calls = append(calls, "a") })
|
|
if err := tx.Nested(ctx, func(n *store.Tx) error {
|
|
n.AfterCommit("", func() { calls = append(calls, "nested") })
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if len(calls) != 0 {
|
|
t.Error("hook ran before commit")
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil || len(calls) != 3 || calls[0] != "k1" || calls[2] != "nested" {
|
|
t.Fatalf("calls %v %v", calls, err)
|
|
}
|
|
calls = nil
|
|
_ = st.InTx(ctx, func(tx *store.Tx) error {
|
|
tx.AfterCommit("", func() { calls = append(calls, "x") })
|
|
return errors.New("rollback")
|
|
})
|
|
if len(calls) != 0 {
|
|
t.Errorf("hook ran after rollback: %v", calls)
|
|
}
|
|
st.Q().AfterCommit("", func() { calls = append(calls, "now") })
|
|
if len(calls) != 1 {
|
|
t.Errorf("hook without transaction: %v", calls)
|
|
}
|
|
}
|
|
|
|
func TestConfigChangesSince(t *testing.T) {
|
|
st := storetest.New(t)
|
|
ctx := context.Background()
|
|
q := st.Q()
|
|
add := func(action, objType, objID string) {
|
|
t.Helper()
|
|
e := &store.AuditEntry{ActorName: "admin", Action: action, ObjectType: objType, ObjectName: action, ObjectID: &objID}
|
|
if err := q.InsertAudit(ctx, e); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
f := store.ChangeFilter{ObjectTypes: []string{"network", "address"}, Actions: []string{"settings.generator"}, Limit: 10}
|
|
add("network.create", "network", "n1")
|
|
add("user.create", "user", "u1")
|
|
add("settings.generator", "settings", "generator")
|
|
if got, err := q.ConfigChangesSince(ctx, f); err != nil || len(got) != 2 || got[0].Action != "settings.generator" {
|
|
t.Fatalf("no revision: %+v %v", got, err)
|
|
}
|
|
add("revision.create", "revision", "1")
|
|
add("address.create", "address", "a1")
|
|
add("revision.deploy", "revision", "1")
|
|
f.AfterRevision = 1
|
|
if got, err := q.ConfigChangesSince(ctx, f); err != nil || len(got) != 1 || got[0].Action != "address.create" {
|
|
t.Fatalf("after revision 1: %+v %v", got, err)
|
|
}
|
|
add("revision.rollback", "revision", "2")
|
|
f.AfterRevision = 2
|
|
if got, _ := q.ConfigChangesSince(ctx, f); len(got) != 0 {
|
|
t.Errorf("after rollback: %+v", got)
|
|
}
|
|
// Without an audit entry of the revision, the time bounds the range.
|
|
f.AfterRevision, f.Since = 9, time.Now().Add(time.Hour)
|
|
if got, _ := q.ConfigChangesSince(ctx, f); len(got) != 0 {
|
|
t.Errorf("since the future: %+v", got)
|
|
}
|
|
f.Limit = 1
|
|
f.Since = time.Time{}
|
|
if got, _ := q.ConfigChangesSince(ctx, f); len(got) != 1 || got[0].Action != "address.create" {
|
|
t.Errorf("limit: %+v", got)
|
|
}
|
|
}
|