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

130 lines
5.1 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package agentapi
import (
"net/netip"
"strconv"
"testing"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/protobuf/types/known/timestamppb"
agentv1 "git.beugsiud.de/beug.k/DDI/proto/gen/ddi/agent/v1"
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
"git.beugsiud.de/beug.k/DDI/server/internal/service"
)
// connect opens a stream, sends Hello and returns the Welcome.
func connect(t *testing.T, a *testAgent, applied uint64) (agentv1.AgentService_ConnectClient, *agentv1.Welcome) {
t.Helper()
stream, err := a.client.Connect(ctxT(t))
if err != nil {
t.Fatal(err)
}
if err := stream.Send(&agentv1.ConnectRequest{Message: &agentv1.ConnectRequest_Hello{Hello: &agentv1.Hello{
AppliedRevision: applied, LastGoodRevision: applied}}}); err != nil {
t.Fatal(err)
}
msg, err := stream.Recv()
if err != nil || msg.GetWelcome() == nil {
t.Fatalf("welcome %v %v", msg, err)
}
return stream, msg.GetWelcome()
}
func TestPushModesAndReports(t *testing.T) {
e := newTestEnv(t)
ctx := ctxT(t)
a := e.enroll("dns")
e.configureGenerator(3600)
if _, _, err := e.svc.CreateRevision(ctx, service.SystemActor, ""); err != nil {
t.Fatal(err)
}
if ag, err := e.svc.SetAgentMode(ctx, service.SystemActor, a.id, "manual"); err != nil || ag.TargetRevision != 1 {
t.Fatalf("mode %+v %v", ag, err)
}
// A push while the agent is offline is announced by polling ...
targets, err := e.svc.DeployRevision(ctx, service.SystemActor, 1, []string{a.id})
if err != nil || len(targets) != 1 || targets[0].Sent {
t.Fatalf("deploy %+v %v", targets, err)
}
id := strconv.FormatInt(targets[0].DeploymentID, 10)
st, err := a.client.GetDesiredState(ctx, &agentv1.GetDesiredStateRequest{})
if err != nil || st.GetDeploymentId() != id || st.GetDesiredRevision() != 1 || st.GetMode() != agentv1.OperatingMode_OPERATING_MODE_MANUAL {
t.Fatalf("desired state %v %v", st, err)
}
// ... and sent again when it connects.
stream, w := connect(t, a, 0)
if w.GetMode() != agentv1.OperatingMode_OPERATING_MODE_MANUAL || w.GetDesiredRevision() != 1 {
t.Errorf("welcome %v", w)
}
msg, err := stream.Recv()
if err != nil || msg.GetCommand().GetApplyRevision().GetRevision() != 1 || msg.GetCommand().GetApplyRevision().GetDeploymentId() != id {
t.Fatalf("pending push %v %v", msg, err)
}
// The report completes the deployment; it is not announced any more and
// a resent report is accepted without changes.
report := &agentv1.ReportDeploymentRequest{DeploymentId: id, Revision: 1,
Trigger: agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_PUSH, Outcome: agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_SUCCEEDED,
ActiveRevision: 1, StartedAt: timestamppb.New(time.Now().Add(-time.Second)), FinishedAt: timestamppb.Now()}
for range 2 {
if _, err := a.client.ReportDeployment(ctx, report); err != nil {
t.Fatal(err)
}
}
deps, err := e.svc.ListDeployments(ctx, service.DeploymentFilter{AgentID: a.id})
if err != nil || len(deps.Items) != 1 || deps.Items[0].Outcome != "succeeded" || deps.Items[0].FinishedAt == nil ||
deps.Items[0].RequestedByName != "system" {
t.Fatalf("deployments %+v %v", deps, err)
}
if st, err := a.client.GetDesiredState(ctx, &agentv1.GetDesiredStateRequest{}); err != nil || st.GetDeploymentId() != "" {
t.Errorf("desired state after report %v %v", st, err)
}
// A manual agent gets no new revisions, but a push while connected.
var netID string
if err := e.st.Pool().QueryRow(ctx, `SELECT id FROM networks`).Scan(&netID); err != nil {
t.Fatal(err)
}
if _, err := e.svc.CreateAddress(ctx, service.SystemActor, service.AddressInput{NetworkID: netID,
IP: netip.MustParseAddr("192.0.2.10"), Type: ipam.TypeStatic, Hostname: "www"}); err != nil {
t.Fatal(err)
}
if rev, created, err := e.svc.CreateRevision(ctx, service.SystemActor, ""); err != nil || !created || rev.Number != 2 {
t.Fatalf("revision 2 %+v %v %v", rev, created, err)
}
targets, err = e.svc.DeployRevision(ctx, service.SystemActor, 2, nil)
if err != nil || len(targets) != 1 || !targets[0].Sent {
t.Fatalf("push %+v %v", targets, err)
}
msg, err = stream.Recv()
if err != nil || msg.GetCommand().GetApplyRevision().GetRevision() != 2 ||
msg.GetCommand().GetApplyRevision().GetDeploymentId() != strconv.FormatInt(targets[0].DeploymentID, 10) {
t.Fatalf("push command %v %v", msg, err)
}
// A mode change reconnects the agent with the new mode.
if _, err := e.svc.SetAgentMode(ctx, service.SystemActor, a.id, "auto_pull"); err != nil {
t.Fatal(err)
}
_, err = stream.Recv()
wantCode(t, err, codes.Unavailable)
stream, w = connect(t, a, 1)
if w.GetMode() != agentv1.OperatingMode_OPERATING_MODE_AUTO_PULL || w.GetDesiredRevision() != 2 {
t.Errorf("welcome after mode change %v", w)
}
// The open push of revision 2 is still pending (the desired revision).
msg, err = stream.Recv()
if err != nil || msg.GetCommand().GetApplyRevision().GetRevision() != 2 {
t.Errorf("open push after reconnect %v %v", msg, err)
}
eventually(t, "connected", func() bool { return e.hub.Connected(a.id) })
if err := stream.CloseSend(); err != nil {
t.Fatal(err)
}
}