Files
DDI/server/internal/store/push_test.go
T
beug.k@beugsiud.deandClaude Opus 5.5 28dc177310 feat(store): migration 00004 for push deployments, target revisions and revision sources
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>
2026-10-13 20:23:45 +02:00

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