- 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>
168 lines
7.0 KiB
Go
168 lines
7.0 KiB
Go
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
|
|
|
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/netip"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/store/storetest"
|
|
)
|
|
|
|
func addrPtr(s string) *netip.Addr {
|
|
a := netip.MustParseAddr(s)
|
|
return &a
|
|
}
|
|
|
|
func intPtr(v int) *int { return &v }
|
|
|
|
// testSource covers the types that must survive JSON: netip values (also
|
|
// zero values and pointers), maps, nil and empty slices, times.
|
|
func testSource(serial uint32) configgen.Source {
|
|
at := time.Date(2026, 10, 13, 12, 0, 0, 123456000, time.FixedZone("CEST", 2*3600))
|
|
return configgen.Source{
|
|
Serial: serial,
|
|
Networks: []ipam.Network{
|
|
{ID: "n1", CIDR: netip.MustParsePrefix("192.0.2.0/24"), Name: "Office", Gateway: addrPtr("192.0.2.1"),
|
|
Domain: "office.test", SearchDomains: []string{"office.test", "example.test"}, DHCPEnabled: true,
|
|
LeaseTime: intPtr(7200), DNSServers: []netip.Addr{netip.MustParseAddr("192.0.2.53")},
|
|
NTPServers: []netip.Addr{}, Pools: []ipam.Pool{{Start: netip.MustParseAddr("192.0.2.100"), End: netip.MustParseAddr("192.0.2.199")}},
|
|
Version: 3, CreatedAt: at, UpdatedAt: at},
|
|
{ID: "n2", CIDR: netip.MustParsePrefix("2001:db8:1::/64"), Name: "v6", Domain: "v6.test"},
|
|
{ID: "n3", CIDR: netip.MustParsePrefix("198.51.100.0/25"), Name: "Lab", DHCPEnabled: true},
|
|
},
|
|
Addresses: []ipam.Address{
|
|
{ID: "a1", NetworkID: "n1", IP: netip.MustParseAddr("192.0.2.53"), Type: ipam.TypeStatic, Hostname: "ns1",
|
|
FQDN: "ns1.office.test", Tags: []string{"dns"}, CreatedAt: at, UpdatedAt: at},
|
|
{ID: "a2", NetworkID: "n1", IP: netip.MustParseAddr("192.0.2.10"), Type: ipam.TypeManualDHCP,
|
|
MAC: "00:11:22:33:44:55", Hostname: "printer", FQDN: "printer.office.test", LastSeen: &at},
|
|
{ID: "a3", NetworkID: "n1", IP: netip.MustParseAddr("192.0.2.11"), Type: ipam.TypeManualDHCP, Hostname: "nomac"},
|
|
{ID: "a4", NetworkID: "n2", IP: netip.MustParseAddr("2001:db8:1::10"), Type: ipam.TypeStatic, Hostname: "web",
|
|
FQDN: "web.v6.test"},
|
|
{ID: "a5", NetworkID: "n1", IP: netip.MustParseAddr("192.0.2.12"), Type: ipam.TypeReserved},
|
|
},
|
|
SubnetIDs: map[string]uint32{"n1": 1, "n2": 2},
|
|
DNS: configgen.DNSSettings{
|
|
ZoneDir: "/var/lib/bind/ddi-suite",
|
|
Options: configgen.DNSOptions{Recursion: true, AllowRecursion: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}, Forwarders: []netip.Addr{netip.MustParseAddr("9.9.9.9")}},
|
|
DefaultTTL: 3600,
|
|
SOA: configgen.SOA{PrimaryNS: "ns1.office.test", Hostmaster: "hostmaster.office.test", Refresh: 3600, Retry: 600, Expire: 604800, Minimum: 300},
|
|
NameServers: []configgen.NameServer{{Name: "ns1.office.test", Addrs: []netip.Addr{netip.MustParseAddr("192.0.2.53")}},
|
|
{Name: "ns2.example.net"}},
|
|
},
|
|
DHCP: configgen.DHCPSettings{Interfaces: []string{"eth0"}, DirectClients: true, ControlSocket: "/run/kea/kea4-ctrl-socket",
|
|
LeaseFile: "/var/lib/kea/kea-leases4.csv", ValidLifetime: 3600},
|
|
}
|
|
}
|
|
|
|
func sameOutput(t *testing.T, a, b *Generated) {
|
|
t.Helper()
|
|
if !sameFiles(revisionFiles(a), revisionFiles(b)) {
|
|
t.Error("rendered files differ")
|
|
}
|
|
if !reflect.DeepEqual(a.Warnings, b.Warnings) || !reflect.DeepEqual(a.DNSError, b.DNSError) || !reflect.DeepEqual(a.DHCPError, b.DHCPError) {
|
|
t.Errorf("warnings or errors differ: %+v / %+v", a, b)
|
|
}
|
|
}
|
|
|
|
func TestSourceJSONRoundTrip(t *testing.T) {
|
|
src := testSource(7)
|
|
orig := render(src)
|
|
if orig.DNSError != nil || orig.DHCPError != nil || len(orig.DNS) == 0 || len(orig.DHCP) == 0 || len(orig.Warnings) == 0 {
|
|
t.Fatalf("test source does not exercise the generator: %+v", orig)
|
|
}
|
|
raw, err := json.Marshal(src)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var back configgen.Source
|
|
if err := json.Unmarshal(raw, &back); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sameOutput(t, orig, render(back))
|
|
again, _ := json.Marshal(back)
|
|
if !bytes.Equal(raw, again) {
|
|
t.Errorf("JSON is not stable:\n%s\n%s", raw, again)
|
|
}
|
|
if back.Networks[0].Gateway == nil || *back.Networks[0].Gateway != *src.Networks[0].Gateway ||
|
|
back.Networks[1].Gateway != nil || back.Networks[0].NTPServers == nil || back.Networks[1].DNSServers != nil ||
|
|
back.SubnetIDs["n2"] != 2 || !back.Networks[0].CreatedAt.Equal(src.Networks[0].CreatedAt) {
|
|
t.Errorf("values changed: %+v", back.Networks)
|
|
}
|
|
// Another serial changes only the SOA serial.
|
|
back.Serial = 8
|
|
if sameFiles(revisionFiles(orig), revisionFiles(render(back))) {
|
|
t.Error("serial did not change the zones")
|
|
}
|
|
}
|
|
|
|
// TestStoredSourceReRendersIdentically: the source stored with a revision
|
|
// (through jsonb) renders the revision's files byte for byte.
|
|
func TestStoredSourceReRendersIdentically(t *testing.T) {
|
|
st := storetest.New(t)
|
|
ctx := context.Background()
|
|
svc := New(Options{Store: st})
|
|
a := SystemActor
|
|
|
|
g := DefaultGeneratorSettings()
|
|
g.DNS.PrimaryNS = "ns1.office.test"
|
|
g.DNS.Hostmaster = "hostmaster@office.test"
|
|
g.DNS.NameServers = []GeneratorNS{{Name: "ns1.office.test", Addresses: []string{"192.0.2.53"}}}
|
|
g.DNS.Recursion = true
|
|
g.DNS.AllowRecursion = []string{"192.0.2.0/24", "2001:db8::/32"}
|
|
g.DNS.Forwarders = []string{"9.9.9.9"}
|
|
if _, err := svc.SetGeneratorSettings(ctx, a, g); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
n4, err := svc.CreateNetwork(ctx, a, NetworkInput{CIDR: netip.MustParsePrefix("192.0.2.0/24"), Name: "Office",
|
|
Gateway: addrPtr("192.0.2.1"), Domain: "office.test", DHCPEnabled: true, LeaseTime: intPtr(7200),
|
|
DNSServers: []netip.Addr{netip.MustParseAddr("192.0.2.53")}, SearchDomains: []string{"office.test"},
|
|
Pools: []PoolDTO{{Start: netip.MustParseAddr("192.0.2.100"), End: netip.MustParseAddr("192.0.2.199")}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
n6, err := svc.CreateNetwork(ctx, a, NetworkInput{CIDR: netip.MustParsePrefix("2001:db8:1::/64"), Name: "v6", Domain: "v6.test"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, in := range []AddressInput{
|
|
{NetworkID: n4.ID, IP: netip.MustParseAddr("192.0.2.53"), Type: ipam.TypeStatic, Hostname: "ns1"},
|
|
{NetworkID: n4.ID, IP: netip.MustParseAddr("192.0.2.10"), Type: ipam.TypeManualDHCP, MAC: "00:11:22:33:44:55", Hostname: "printer"},
|
|
{NetworkID: n4.ID, IP: netip.MustParseAddr("192.0.2.150"), Type: ipam.TypeReserved},
|
|
{NetworkID: n6.ID, IP: netip.MustParseAddr("2001:db8:1::10"), Type: ipam.TypeStatic, Hostname: "web"},
|
|
} {
|
|
if _, err := svc.CreateAddress(ctx, a, in); err != nil {
|
|
t.Fatalf("address %v: %v", in.IP, err)
|
|
}
|
|
}
|
|
rev, created, err := svc.CreateRevision(ctx, a, "")
|
|
if err != nil || !created {
|
|
t.Fatalf("revision: %v %v", created, err)
|
|
}
|
|
raw, err := st.Q().RevisionSource(ctx, rev.Number)
|
|
if err != nil || raw == nil {
|
|
t.Fatalf("source %s %v", raw, err)
|
|
}
|
|
var src configgen.Source
|
|
if err := json.Unmarshal(raw, &src); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if src.Serial != uint32(rev.Number) { //nolint:gosec // small
|
|
t.Errorf("stored serial %d", src.Serial)
|
|
}
|
|
stored, err := st.Q().RevisionFiles(ctx, rev.Number, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !sameFiles(revisionFiles(render(src)), stored) {
|
|
t.Error("the stored source does not render the stored files")
|
|
}
|
|
}
|