- 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>
583 lines
21 KiB
Go
583 lines
21 KiB
Go
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
|
|
|
package httpapi
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
agentv1 "git.beugsiud.de/beug.k/DDI/proto/gen/ddi/agent/v1"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/service"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/store"
|
|
)
|
|
|
|
// officeNetwork creates a network with a name server entry.
|
|
func (c *client) officeNetwork() {
|
|
c.e.t.Helper()
|
|
c.mustStatus(201, "POST", "/api/v1/networks", map[string]any{
|
|
"cidr": "192.0.2.0/24", "name": "Office", "gateway": "192.0.2.1", "domain": "office.test",
|
|
"dhcp_enabled": true, "pools": []map[string]string{{"start": "192.0.2.100", "end": "192.0.2.199"}},
|
|
})
|
|
c.mustStatus(201, "POST", "/api/v1/addresses", map[string]any{"ip": "192.0.2.53", "type": "static", "hostname": "ns1"})
|
|
}
|
|
|
|
func (c *client) addHost(ip, name string) {
|
|
c.e.t.Helper()
|
|
c.mustStatus(201, "POST", "/api/v1/addresses", map[string]any{"ip": ip, "type": "static", "hostname": name})
|
|
}
|
|
|
|
func (c *client) newRevision() service.RevisionDTO {
|
|
c.e.t.Helper()
|
|
var rev service.RevisionDTO
|
|
c.mustStatus(201, "POST", "/api/v1/revisions", nil).json(c.e.t, &rev)
|
|
return rev
|
|
}
|
|
|
|
func (c *client) agent(id string) service.AgentDTO {
|
|
c.e.t.Helper()
|
|
var ag service.AgentDTO
|
|
c.mustStatus(200, "GET", "/api/v1/agents/"+id, nil).json(c.e.t, &ag)
|
|
return ag
|
|
}
|
|
|
|
type deployResponse = listResponse[service.DeployTarget]
|
|
|
|
func (c *client) deploy(n int64, agentIDs ...string) []service.DeployTarget {
|
|
c.e.t.Helper()
|
|
var body any
|
|
if len(agentIDs) > 0 {
|
|
body = map[string]any{"agent_ids": agentIDs}
|
|
}
|
|
var out deployResponse
|
|
c.mustStatus(200, "POST", "/api/v1/revisions/"+strconv.FormatInt(n, 10)+"/deploy", body).json(c.e.t, &out)
|
|
return out.Items
|
|
}
|
|
|
|
// command returns the next command of conn or fails.
|
|
func command(t *testing.T, conn *agenthub.Conn) *agentv1.Command {
|
|
t.Helper()
|
|
select {
|
|
case cmd := <-conn.Commands():
|
|
return cmd
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("no command")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func noCommand(t *testing.T, conn *agenthub.Conn) {
|
|
t.Helper()
|
|
select {
|
|
case cmd := <-conn.Commands():
|
|
t.Errorf("unexpected command %v", cmd)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (e *env) deployment(id int64) service.DeploymentDTO {
|
|
e.t.Helper()
|
|
page, err := e.svc.ListDeployments(context.Background(), service.DeploymentFilter{Limit: 500})
|
|
if err != nil {
|
|
e.t.Fatal(err)
|
|
}
|
|
for _, d := range page.Items {
|
|
if d.ID == id {
|
|
return d
|
|
}
|
|
}
|
|
e.t.Fatalf("deployment %d not found", id)
|
|
return service.DeploymentDTO{}
|
|
}
|
|
|
|
func TestAgentModesAndPush(t *testing.T) {
|
|
e := newEnv(t)
|
|
admin := e.adminClient()
|
|
viewer := e.userClient(admin, "vera", "viewer")
|
|
operator := e.userClient(admin, "otto", "operator")
|
|
ctx := context.Background()
|
|
admin.officeNetwork()
|
|
admin.configureDNS()
|
|
|
|
auto := e.enroll(admin.createToken("dns").Secret, "dns")
|
|
manual := e.enroll(admin.createToken("dhcp").Secret, "dhcp")
|
|
if rev := operator.newRevision(); rev.Number != 1 {
|
|
t.Fatalf("revision %d", rev.Number)
|
|
}
|
|
|
|
// Switching to manual keeps the agent on the latest revision and
|
|
// reconnects it.
|
|
conn := e.hub.Attach(manual, "dhcp", agenthub.ModeAutoPull)
|
|
viewer.mustStatus(403, "PATCH", "/api/v1/agents/"+manual, map[string]any{"mode": "manual"})
|
|
operator.mustStatus(403, "PATCH", "/api/v1/agents/"+manual, map[string]any{"mode": "manual"})
|
|
admin.mustStatus(422, "PATCH", "/api/v1/agents/"+manual, map[string]any{"mode": "sometimes"}, skipRequestValidation, "1")
|
|
admin.mustStatus(404, "PATCH", "/api/v1/agents/00000000-0000-4000-8000-000000000000", map[string]any{"mode": "manual"})
|
|
var ag service.AgentDTO
|
|
admin.mustStatus(200, "PATCH", "/api/v1/agents/"+manual, map[string]any{"mode": "manual"}).json(t, &ag)
|
|
if ag.Mode != "manual" || ag.TargetRevision != 1 || ag.DesiredRevision != 1 || ag.Connected {
|
|
t.Errorf("manual agent %+v", ag)
|
|
}
|
|
if conn.Reason() != agenthub.ReasonModeChanged {
|
|
t.Errorf("stream not closed: %q", conn.Reason())
|
|
}
|
|
// Unchanged mode: no audit entry, no reconnect.
|
|
mconn := e.hub.Attach(manual, "dhcp", agenthub.ModeManual)
|
|
admin.mustStatus(200, "PATCH", "/api/v1/agents/"+manual, map[string]any{"mode": "manual"})
|
|
select {
|
|
case <-mconn.Done():
|
|
t.Error("unchanged mode closed the stream")
|
|
default:
|
|
}
|
|
var audit auditPage
|
|
admin.mustStatus(200, "GET", "/api/v1/audit?action=agent.update", nil).json(t, &audit)
|
|
if len(audit.Items) != 1 {
|
|
t.Errorf("mode audit %+v", audit.Items)
|
|
}
|
|
|
|
// A new revision goes to auto_pull agents only.
|
|
aconn := e.hub.Attach(auto, "dns", agenthub.ModeAutoPull)
|
|
admin.addHost("192.0.2.10", "www")
|
|
if rev := operator.newRevision(); rev.Number != 2 {
|
|
t.Fatalf("revision %d", rev.Number)
|
|
}
|
|
if cmd := command(t, aconn); cmd.GetApplyRevision().GetRevision() != 2 {
|
|
t.Errorf("auto_pull command %v", cmd)
|
|
}
|
|
noCommand(t, mconn)
|
|
if a := viewer.agent(auto); a.DesiredRevision != 2 || a.TargetRevision != 0 {
|
|
t.Errorf("auto_pull agent %+v", a)
|
|
}
|
|
if m := viewer.agent(manual); m.DesiredRevision != 1 {
|
|
t.Errorf("manual agent %+v", m)
|
|
}
|
|
|
|
// Pushing an older revision to an auto_pull agent needs a rollback.
|
|
r := operator.mustStatus(422, "POST", "/api/v1/revisions/1/deploy", nil)
|
|
if p := r.problem(t); p.Code != "rollback_required" || p.Params["latest"] != "2" || len(p.Problems) != 1 ||
|
|
p.Problems[0].Params["agent_id"] != auto {
|
|
t.Errorf("rollback required %+v", p)
|
|
}
|
|
viewer.mustStatus(403, "POST", "/api/v1/revisions/1/deploy", map[string]any{"agent_ids": []string{manual}})
|
|
operator.mustStatus(404, "POST", "/api/v1/revisions/99/deploy", nil)
|
|
r = operator.mustStatus(422, "POST", "/api/v1/revisions/1/deploy",
|
|
map[string]any{"agent_ids": []string{"nonsense", "00000000-0000-4000-8000-000000000000"}})
|
|
if p := r.problem(t); p.Code != "validation_failed" || len(p.Problems) != 2 || p.Problems[0].Code != "unknown_agent" ||
|
|
p.Problems[0].Field != "agent_ids" {
|
|
t.Errorf("unknown agents %+v", p)
|
|
}
|
|
|
|
// Push revision 1 to the manual agent: pending deployment, command
|
|
// with the deployment ID.
|
|
targets := operator.deploy(1, manual)
|
|
if len(targets) != 1 || targets[0].AgentID != manual || !targets[0].Sent || targets[0].DeploymentID == 0 ||
|
|
targets[0].AgentHostname != "host-dhcp" {
|
|
t.Fatalf("targets %+v", targets)
|
|
}
|
|
first := targets[0].DeploymentID
|
|
cmd := command(t, mconn)
|
|
if cmd.GetApplyRevision().GetRevision() != 1 || cmd.GetApplyRevision().GetDeploymentId() != strconv.FormatInt(first, 10) {
|
|
t.Errorf("push command %v", cmd)
|
|
}
|
|
if d := e.deployment(first); d.Outcome != "pending" || d.FinishedAt != nil || d.RequestedByName != "otto" || d.Trigger != "push" {
|
|
t.Errorf("pending deployment %+v", d)
|
|
}
|
|
st, err := e.svc.AgentDesiredState(ctx, manual, nil)
|
|
if err != nil || st.DesiredRevision != 1 || st.PushDeploymentID != strconv.FormatInt(first, 10) {
|
|
t.Errorf("desired state %+v %v", st, err)
|
|
}
|
|
|
|
// A newer push supersedes the open one and moves the target.
|
|
second := operator.deploy(2, manual)[0].DeploymentID
|
|
command(t, mconn)
|
|
if d := e.deployment(first); d.Outcome != "superseded" || d.FinishedAt == nil {
|
|
t.Errorf("superseded %+v", d)
|
|
}
|
|
if m := viewer.agent(manual); m.TargetRevision != 2 || m.DesiredRevision != 2 || m.LastDeployment == nil ||
|
|
m.LastDeployment.ID != second {
|
|
t.Errorf("manual agent after push %+v", m)
|
|
}
|
|
|
|
// The agent reports: the deployment is completed, polling no longer
|
|
// announces it, a resent report changes nothing.
|
|
started := e.now.Add(-time.Second).Truncate(time.Microsecond)
|
|
rep := service.DeploymentReport{CorrelationID: strconv.FormatInt(second, 10), Revision: 2, Trigger: "push",
|
|
Outcome: "succeeded", Message: "ok", ActiveRevision: 2, StartedAt: started, FinishedAt: *e.now}
|
|
for range 2 {
|
|
if err := e.svc.RecordDeployment(ctx, manual, rep); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if d := e.deployment(second); d.Outcome != "succeeded" || d.FinishedAt == nil || d.Message != "ok" || !d.StartedAt.Equal(started) {
|
|
t.Errorf("completed %+v", d)
|
|
}
|
|
if st, _ := e.svc.AgentDesiredState(ctx, manual, nil); st.PushDeploymentID != "" || st.DesiredRevision != 2 {
|
|
t.Errorf("desired state after report %+v", st)
|
|
}
|
|
var deps service.DeploymentPage
|
|
viewer.mustStatus(200, "GET", "/api/v1/deployments?agent_id="+manual, nil).json(t, &deps)
|
|
if len(deps.Items) != 2 {
|
|
t.Errorf("deployments %+v", deps.Items)
|
|
}
|
|
if m := viewer.agent(manual); m.AppliedRevision != 2 || m.LastGoodRevision != 2 {
|
|
t.Errorf("agent state after report %+v", m)
|
|
}
|
|
// A report with an unknown push ID is stored like a pull report.
|
|
if err := e.svc.RecordDeployment(ctx, manual, service.DeploymentReport{CorrelationID: "999999", Revision: 2,
|
|
Trigger: "push", Outcome: "succeeded", ActiveRevision: 2, StartedAt: *e.now, FinishedAt: *e.now}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// No agent goes back behind the revision it runs.
|
|
r = operator.mustStatus(422, "POST", "/api/v1/revisions/1/deploy", map[string]any{"agent_ids": []string{manual}})
|
|
if p := r.problem(t); p.Code != "rollback_required" || p.Problems[0].Params["applied_revision"] != "2" {
|
|
t.Errorf("older than applied %+v", p)
|
|
}
|
|
|
|
// Push to all agents: the latest revision is fine for both modes.
|
|
all := operator.deploy(2)
|
|
if len(all) != 2 {
|
|
t.Fatalf("push to all %+v", all)
|
|
}
|
|
if cmd := command(t, aconn); cmd.GetApplyRevision().GetDeploymentId() == "" {
|
|
t.Errorf("auto_pull push %v", cmd)
|
|
}
|
|
command(t, mconn)
|
|
|
|
// Disconnected agents get the push later (sent false).
|
|
e.hub.Disconnect(auto, "test")
|
|
var autoPush int64
|
|
for _, tg := range operator.deploy(2, auto) {
|
|
if tg.Sent {
|
|
t.Errorf("sent to a disconnected agent %+v", tg)
|
|
}
|
|
autoPush = tg.DeploymentID
|
|
}
|
|
set, err := e.svc.AgentHello(ctx, auto, service.HelloInput{AppliedRevision: 2, LastGoodRevision: 2})
|
|
if err != nil || set.PushDeploymentID != strconv.FormatInt(autoPush, 10) || set.DesiredRevision != 2 {
|
|
t.Errorf("hello settings %+v %v", set, err)
|
|
}
|
|
|
|
// A new revision supersedes the open push of the auto_pull agent.
|
|
admin.addHost("192.0.2.11", "mail")
|
|
operator.newRevision()
|
|
if d := e.deployment(autoPush); d.Outcome != "superseded" {
|
|
t.Errorf("auto_pull push after a new revision %+v", d)
|
|
}
|
|
|
|
// Back to auto_pull: open pushes of older revisions are superseded.
|
|
open := operator.deploy(2, manual)[0].DeploymentID
|
|
admin.mustStatus(200, "PATCH", "/api/v1/agents/"+manual, map[string]any{"mode": "auto_pull"}).json(t, &ag)
|
|
if ag.DesiredRevision != 3 || ag.TargetRevision != 2 {
|
|
t.Errorf("auto_pull again %+v", ag)
|
|
}
|
|
if d := e.deployment(open); d.Outcome != "superseded" {
|
|
t.Errorf("open push after mode change %+v", d)
|
|
}
|
|
|
|
// Revoked agents are rejected; read-only mode blocks pushes.
|
|
admin.mustStatus(200, "POST", "/api/v1/agents/"+manual+"/revoke", nil)
|
|
r = operator.mustStatus(422, "POST", "/api/v1/revisions/3/deploy", map[string]any{"agent_ids": []string{manual}})
|
|
if p := r.problem(t); len(p.Problems) != 1 || p.Problems[0].Code != "agent_revoked" {
|
|
t.Errorf("revoked %+v", p)
|
|
}
|
|
if got := operator.deploy(3); len(got) != 1 || got[0].AgentID != auto {
|
|
t.Errorf("push to all skips revoked agents: %+v", got)
|
|
}
|
|
admin.mustStatus(200, "GET", "/api/v1/audit?action=revision.deploy", nil).json(t, &audit)
|
|
if len(audit.Items) != 6 {
|
|
t.Errorf("deploy audit %d", len(audit.Items))
|
|
}
|
|
e.advance(31 * 24 * time.Hour)
|
|
operator.login("otto", "changed-password-1")
|
|
if p := operator.mustStatus(403, "POST", "/api/v1/revisions/3/deploy", nil).problem(t); p.Code != "license_read_only" {
|
|
t.Errorf("read-only %+v", p)
|
|
}
|
|
}
|
|
|
|
func TestRollbackAndDiff(t *testing.T) {
|
|
e := newEnv(t)
|
|
admin := e.adminClient()
|
|
viewer := e.userClient(admin, "vera", "viewer")
|
|
operator := e.userClient(admin, "otto", "operator")
|
|
admin.officeNetwork()
|
|
admin.configureDNS()
|
|
auto := e.enroll(admin.createToken("dns").Secret, "dns")
|
|
conn := e.hub.Attach(auto, "dns", agenthub.ModeAutoPull)
|
|
|
|
operator.newRevision()
|
|
command(t, conn)
|
|
admin.addHost("192.0.2.10", "www")
|
|
operator.newRevision()
|
|
command(t, conn)
|
|
|
|
var d service.RevisionDiff
|
|
viewer.mustStatus(200, "GET", "/api/v1/revisions/2/diff", nil).json(t, &d)
|
|
if d.Number != 2 || d.Against != 1 || len(d.Files) == 0 {
|
|
t.Fatalf("diff %+v", d)
|
|
}
|
|
var zone *service.FileDiff
|
|
for i := range d.Files {
|
|
if d.Files[i].Status != "modified" || d.Files[i].Role != "dns" {
|
|
t.Errorf("file %+v", d.Files[i])
|
|
}
|
|
if d.Files[i].Path == "zones/db.office.test" {
|
|
zone = &d.Files[i]
|
|
}
|
|
}
|
|
if zone == nil || !strings.Contains(zone.Diff, "192.0.2.10") || zone.Inserted < 3 || zone.Deleted < 2 || !strings.HasPrefix(zone.Diff, "@@ ") {
|
|
t.Errorf("zone diff %+v", zone)
|
|
}
|
|
d = service.RevisionDiff{}
|
|
viewer.mustStatus(200, "GET", "/api/v1/revisions/2/diff?against=0", nil).json(t, &d)
|
|
for _, f := range d.Files {
|
|
if f.Status != "added" || f.Deleted != 0 || f.Inserted == 0 {
|
|
t.Errorf("against nothing %+v", f)
|
|
}
|
|
}
|
|
if len(d.Files) < 2 || d.Files[len(d.Files)-1].Role != "dhcp" {
|
|
t.Errorf("files %+v", d.Files)
|
|
}
|
|
d = service.RevisionDiff{}
|
|
viewer.mustStatus(200, "GET", "/api/v1/revisions/1/diff?against=2", nil).json(t, &d)
|
|
if len(d.Files) == 0 || d.Files[0].Deleted == 0 {
|
|
t.Errorf("reverse diff %+v", d)
|
|
}
|
|
viewer.mustStatus(404, "GET", "/api/v1/revisions/2/diff?against=99", nil)
|
|
viewer.mustStatus(404, "GET", "/api/v1/revisions/99/diff", nil)
|
|
viewer.mustStatus(422, "GET", "/api/v1/revisions/2/diff?against=x", nil, skipRequestValidation, "1")
|
|
|
|
// Rollback: a new revision with the content of revision 1 and a new
|
|
// SOA serial.
|
|
viewer.mustStatus(403, "POST", "/api/v1/revisions/1/rollback", nil)
|
|
operator.mustStatus(404, "POST", "/api/v1/revisions/99/rollback", nil)
|
|
var rev service.RevisionDTO
|
|
operator.mustStatus(201, "POST", "/api/v1/revisions/1/rollback", nil).json(t, &rev)
|
|
if rev.Number != 3 || rev.Comment != "Rollback to revision 1" || rev.CreatedByName != "otto" {
|
|
t.Errorf("rollback %+v", rev)
|
|
}
|
|
if cmd := command(t, conn); cmd.GetApplyRevision().GetRevision() != 3 {
|
|
t.Errorf("rollback not pushed: %v", cmd)
|
|
}
|
|
d = service.RevisionDiff{}
|
|
viewer.mustStatus(200, "GET", "/api/v1/revisions/3/diff?against=1", nil).json(t, &d)
|
|
if len(d.Files) == 0 {
|
|
t.Fatal("no serial change")
|
|
}
|
|
for _, f := range d.Files {
|
|
// Only the revision (header comment, SOA serial) differs.
|
|
if f.Role != "dns" || f.Status != "modified" || f.Inserted != f.Deleted || f.Inserted > 2 ||
|
|
!strings.Contains(f.Diff, "+") || !strings.Contains(f.Diff, "(revision 3)") || strings.Contains(f.Diff, "192.0.2.10") {
|
|
t.Errorf("rollback differs from revision 1: %+v", f)
|
|
}
|
|
}
|
|
d = service.RevisionDiff{}
|
|
viewer.mustStatus(200, "GET", "/api/v1/revisions/3/diff", nil).json(t, &d)
|
|
if zone := findDiff(d.Files, "zones/db.office.test"); zone == nil || !strings.Contains(zone.Diff, "192.0.2.10") || zone.Deleted < 3 {
|
|
t.Errorf("rollback diff against 2: %+v", d.Files)
|
|
}
|
|
rev = service.RevisionDTO{}
|
|
operator.mustStatus(201, "POST", "/api/v1/revisions/3/rollback", map[string]any{"comment": " again "}).json(t, &rev)
|
|
if rev.Number != 4 || rev.Comment != "again" {
|
|
t.Errorf("second rollback %+v", rev)
|
|
}
|
|
command(t, conn)
|
|
var audit auditPage
|
|
admin.mustStatus(200, "GET", "/api/v1/audit?action=revision.rollback", nil).json(t, &audit)
|
|
if len(audit.Items) != 2 || audit.Items[1].ObjectID == nil || *audit.Items[1].ObjectID != "3" {
|
|
t.Errorf("rollback audit %+v", audit.Items)
|
|
}
|
|
// Normal creation still detects unchanged data: the data has www,
|
|
// revision 4 does not.
|
|
if rev := operator.newRevision(); rev.Number != 5 {
|
|
t.Errorf("revision after rollback %+v", rev)
|
|
}
|
|
|
|
// Revisions without generator input (before M4) cannot be rolled back
|
|
// to.
|
|
if err := e.st.Q().InsertRevision(context.Background(), &store.Revision{Number: 6, CreatedAt: *e.now, CreatedByName: "old"}, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p := operator.mustStatus(409, "POST", "/api/v1/revisions/6/rollback", nil).problem(t); p.Code != "rollback_unavailable" {
|
|
t.Errorf("no source %+v", p)
|
|
}
|
|
}
|
|
|
|
func findDiff(files []service.FileDiff, path string) *service.FileDiff {
|
|
for i := range files {
|
|
if files[i].Path == path {
|
|
return &files[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestPendingChanges(t *testing.T) {
|
|
e := newEnv(t)
|
|
admin := e.adminClient()
|
|
viewer := e.userClient(admin, "vera", "viewer")
|
|
admin.officeNetwork()
|
|
|
|
var p service.PendingChanges
|
|
viewer.mustStatus(200, "GET", "/api/v1/config/pending", nil).json(t, &p)
|
|
if p.LatestRevision != 0 || !p.Changed || p.DNSError == nil || p.DNSError.Code != "dns_settings_missing" || p.DHCPError != nil ||
|
|
len(p.Files) != 1 || p.Files[0].Status != "added" || p.Files[0].Role != "dhcp" || len(p.Changes) != 2 {
|
|
t.Fatalf("before the first revision %+v", p)
|
|
}
|
|
if p.Changes[0].Action != "address.create" || p.Changes[1].Action != "network.create" || p.Changes[0].IP != nil {
|
|
t.Errorf("changes %+v", p.Changes)
|
|
}
|
|
|
|
admin.configureDNS()
|
|
admin.newRevision()
|
|
p = service.PendingChanges{}
|
|
viewer.mustStatus(200, "GET", "/api/v1/config/pending", nil).json(t, &p)
|
|
if p.LatestRevision != 1 || p.Changed || len(p.Files) != 0 || len(p.Changes) != 0 || p.DNSError != nil {
|
|
t.Fatalf("after the revision %+v", p)
|
|
}
|
|
|
|
admin.addHost("192.0.2.10", "www")
|
|
admin.mustStatus(201, "POST", "/api/v1/users", map[string]any{"username": "x", "role": "viewer", "password": "initial-password-1"})
|
|
p = service.PendingChanges{}
|
|
viewer.mustStatus(200, "GET", "/api/v1/config/pending", nil).json(t, &p)
|
|
if !p.Changed || len(p.Changes) != 1 || p.Changes[0].Action != "address.create" || findDiff(p.Files, "zones/db.office.test") == nil {
|
|
t.Fatalf("pending %+v", p)
|
|
}
|
|
for _, f := range p.Files {
|
|
if f.Status != "modified" || f.Role != "dns" {
|
|
t.Errorf("pending file %+v", f)
|
|
}
|
|
}
|
|
// The serial is not a change: the pending zone diff contains only the
|
|
// new host.
|
|
if z := findDiff(p.Files, "zones/db.office.test"); z.Deleted != 0 {
|
|
t.Errorf("zone diff %+v", z)
|
|
}
|
|
}
|
|
|
|
// sse reads an event stream line by line.
|
|
type sse struct {
|
|
lines chan string
|
|
done chan struct{}
|
|
keepalives atomic.Int64
|
|
}
|
|
|
|
func (c *client) openEvents(ctx context.Context) (int, http.Header, *sse) {
|
|
c.e.t.Helper()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.e.srv.URL+"/api/v1/events", nil)
|
|
if err != nil {
|
|
c.e.t.Fatal(err)
|
|
}
|
|
res, err := c.http.Do(req) //nolint:bodyclose // closed by the reader goroutine
|
|
if err != nil {
|
|
c.e.t.Fatal(err)
|
|
}
|
|
s := &sse{lines: make(chan string, 1000), done: make(chan struct{})}
|
|
go func() {
|
|
defer close(s.done)
|
|
defer res.Body.Close() //nolint:errcheck // test
|
|
sc := bufio.NewScanner(res.Body)
|
|
for sc.Scan() {
|
|
if sc.Text() == ": keepalive" {
|
|
s.keepalives.Add(1)
|
|
}
|
|
s.lines <- sc.Text()
|
|
}
|
|
}()
|
|
return res.StatusCode, res.Header, s
|
|
}
|
|
|
|
// event waits for the next event of type typ and returns its data.
|
|
func (s *sse) event(t *testing.T, typ string) string {
|
|
t.Helper()
|
|
timeout := time.After(5 * time.Second)
|
|
for {
|
|
select {
|
|
case l := <-s.lines:
|
|
if l != "event: "+typ {
|
|
continue
|
|
}
|
|
select {
|
|
case data := <-s.lines:
|
|
return strings.TrimPrefix(data, "data: ")
|
|
case <-timeout:
|
|
}
|
|
t.Fatalf("event %s without data", typ)
|
|
case <-timeout:
|
|
t.Fatalf("no %s event", typ)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEventStream(t *testing.T) {
|
|
// Short server timeouts: the stream must outlive them.
|
|
e := newEnvServer(t, nil, func(s *http.Server) {
|
|
s.ReadTimeout, s.WriteTimeout = 4*time.Second, 4*time.Second
|
|
})
|
|
admin := e.adminClient()
|
|
e.client().mustStatus(401, "GET", "/api/v1/events", nil)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
status, header, stream := admin.openEvents(ctx)
|
|
if status != http.StatusOK || header.Get("Content-Type") != "text/event-stream" || header.Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("stream response %d %v", status, header)
|
|
}
|
|
if l := <-stream.lines; l != "retry: 5000" {
|
|
t.Errorf("first line %q", l)
|
|
}
|
|
if e.bus.Subscribers() != 1 {
|
|
t.Errorf("subscribers %d", e.bus.Subscribers())
|
|
}
|
|
|
|
admin.officeNetwork()
|
|
if data := stream.event(t, "pending"); data != "{}" {
|
|
t.Errorf("pending data %q", data)
|
|
}
|
|
agentID := e.enroll(admin.createToken("dns").Secret, "dns")
|
|
if data := stream.event(t, "agent"); data != `{"id":"`+agentID+`"}` {
|
|
t.Errorf("agent data %q", data)
|
|
}
|
|
|
|
// Past the server's write timeout the stream still delivers.
|
|
time.Sleep(5 * time.Second)
|
|
admin.configureDNS()
|
|
stream.event(t, "pending")
|
|
rev := admin.newRevision()
|
|
if data := stream.event(t, "revision"); data != `{"number":`+strconv.FormatInt(rev.Number, 10)+`}` {
|
|
t.Errorf("revision data %q", data)
|
|
}
|
|
var targets deployResponse
|
|
admin.mustStatus(200, "POST", "/api/v1/revisions/1/deploy", nil).json(t, &targets)
|
|
var dep struct {
|
|
ID int64 `json:"id"`
|
|
AgentID string `json:"agent_id"`
|
|
Revision int64 `json:"revision"`
|
|
}
|
|
if err := json.Unmarshal([]byte(stream.event(t, "deployment")), &dep); err != nil || dep.ID != targets.Items[0].DeploymentID ||
|
|
dep.AgentID != agentID || dep.Revision != 1 {
|
|
t.Errorf("deployment event %+v %v", dep, err)
|
|
}
|
|
if n := stream.keepalives.Load(); n < 10 {
|
|
t.Errorf("keepalives %d", n)
|
|
}
|
|
|
|
// Logging out ends the stream at the next session check.
|
|
admin.mustStatus(204, "POST", "/api/v1/auth/logout", nil)
|
|
select {
|
|
case <-stream.done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("stream outlived the session")
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
if n := e.bus.Subscribers(); n != 0 {
|
|
t.Errorf("subscribers after the end %d", n)
|
|
}
|
|
}
|