Christian Grabowski has proposed merging 
~cgrabowski/maas-ci/+git/system-tests:add_dns_tests into 
~maas-committers/maas-ci/+git/system-tests:master.

Commit message:
fix fqdn comparison

add marker to pyproject.toml

fix passing args to Go bin

add pytests for DNS tests

add dnstester Go code



Requested reviews:
  MAAS Committers (maas-committers)

For more details, see:
https://code.launchpad.net/~cgrabowski/maas-ci/+git/system-tests/+merge/443618
-- 
Your team MAAS Committers is requested to review the proposed merge of 
~cgrabowski/maas-ci/+git/system-tests:add_dns_tests into 
~maas-committers/maas-ci/+git/system-tests:master.
diff --git a/config.yaml.sample b/config.yaml.sample
index 6012921..91f5180 100644
--- a/config.yaml.sample
+++ b/config.yaml.sample
@@ -123,3 +123,8 @@ ansible-playbooks:
     git-branch: main
     verbosity:
     floating-ip-network: lxdbr0
+
+dns_tests:
+    server: 10.245.136.5:53
+    zone: maas
+    duration: 60 # seconds
diff --git a/dnstester/go.mod b/dnstester/go.mod
new file mode 100644
index 0000000..38f36db
--- /dev/null
+++ b/dnstester/go.mod
@@ -0,0 +1,22 @@
+module launchpad.net/maas-ci/system-tests/dnstester
+
+go 1.18
+
+require (
+	github.com/google/go-querystring v1.1.0 // indirect
+	github.com/juju/collections v0.0.0-20220203020748-febd7cad8a7a // indirect
+	github.com/juju/errors v0.0.0-20220203013757-bd733f3c86b9 // indirect
+	github.com/juju/gomaasapi/v2 v2.0.1 // indirect
+	github.com/juju/loggo v0.0.0-20210728185423-eebad3a902c4 // indirect
+	github.com/juju/mgo/v2 v2.0.0-20220111072304-f200228f1090 // indirect
+	github.com/juju/schema v1.0.1-0.20190814234152-1f8aaeef0989 // indirect
+	github.com/juju/version v0.0.0-20191219164919-81c1be00b9a6 // indirect
+	github.com/maas/gomaasclient v0.0.0-20230512141257-d73401ee0dc8 // indirect
+	github.com/miekg/dns v1.1.54 // indirect
+	golang.org/x/mod v0.7.0 // indirect
+	golang.org/x/net v0.2.0 // indirect
+	golang.org/x/sync v0.2.0 // indirect
+	golang.org/x/sys v0.2.0 // indirect
+	golang.org/x/tools v0.3.0 // indirect
+	gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 // indirect
+)
diff --git a/dnstester/go.sum b/dnstester/go.sum
new file mode 100644
index 0000000..74c2537
--- /dev/null
+++ b/dnstester/go.sum
@@ -0,0 +1,55 @@
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
+github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU=
+github.com/juju/collections v0.0.0-20220203020748-febd7cad8a7a h1:d7eZO8OS/ZXxdP0uq3E8CdoA1qNFaecAv90UxrxaY2k=
+github.com/juju/collections v0.0.0-20220203020748-febd7cad8a7a/go.mod h1:JWeZdyttIEbkR51z2S13+J+aCuHVe0F6meRy+P0YGDo=
+github.com/juju/errors v0.0.0-20220203013757-bd733f3c86b9 h1:EJHbsNpQyupmMeWTq7inn+5L/WZ7JfzCVPJ+DP9McCQ=
+github.com/juju/errors v0.0.0-20220203013757-bd733f3c86b9/go.mod h1:TRm7EVGA3mQOqSVcBySRY7a9Y1/gyVhh/WTCnc5sD4U=
+github.com/juju/gomaasapi/v2 v2.0.1 h1:rulAepQ48AIdSLlW3Dk6bgdVyppycfap3RlYHCdKfpM=
+github.com/juju/gomaasapi/v2 v2.0.1/go.mod h1:ZsohFbU4xShV1aSQYQ21hR1lKj7naNGY0SPuyelcUmk=
+github.com/juju/loggo v0.0.0-20210728185423-eebad3a902c4 h1:NO5tuyw++EGLnz56Q8KMyDZRwJwWO8jQnj285J3FOmY=
+github.com/juju/loggo v0.0.0-20210728185423-eebad3a902c4/go.mod h1:NIXFioti1SmKAlKNuUwbMenNdef59IF52+ZzuOmHYkg=
+github.com/juju/mgo/v2 v2.0.0-20220111072304-f200228f1090 h1:zX5GoH3Jp8k1EjUFkApu/YZAYEn0PYQfg/U6IDyNyYs=
+github.com/juju/mgo/v2 v2.0.0-20220111072304-f200228f1090/go.mod h1:N614SE0a4e+ih2rg96Vi2PeC3cTpUOWgCTv3Cgk974c=
+github.com/juju/schema v1.0.1-0.20190814234152-1f8aaeef0989 h1:qx1Zh1bnHHVIMmRxq0fehYk7npCG50GhUwEkYeUg/t4=
+github.com/juju/schema v1.0.1-0.20190814234152-1f8aaeef0989/go.mod h1:Y+ThzXpUJ0E7NYYocAbuvJ7vTivXfrof/IfRPq/0abI=
+github.com/juju/version v0.0.0-20191219164919-81c1be00b9a6 h1:nrqc9b4YKpKV4lPI3GPPFbo5FUuxkWxgZE2Z8O4lgaw=
+github.com/juju/version v0.0.0-20191219164919-81c1be00b9a6/go.mod h1:kE8gK5X0CImdr7qpSKl3xB2PmpySSmfj7zVbkZFs81U=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/lunixbochs/vtclean v0.0.0-20160125035106-4fbf7632a2c6/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
+github.com/maas/gomaasclient v0.0.0-20230512141257-d73401ee0dc8 h1:wf/KOTST4X1GUfxDOFcjz5VfDyexQZFCzhOlsND7a4I=
+github.com/maas/gomaasclient v0.0.0-20230512141257-d73401ee0dc8/go.mod h1:Hk4F7B10Ww4s+TXXqPpHuWD0GjNqGStlkZPANHOO+BA=
+github.com/mattn/go-colorable v0.0.6/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/mattn/go-isatty v0.0.0-20160806122752-66b8e73f3f5c/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/miekg/dns v1.1.54 h1:5jon9mWcb0sFJGpnI99tOMhCPyJ+RPVz5b63MQG0VWI=
+github.com/miekg/dns v1.1.54/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
+github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
+golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
+golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
+golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
+golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
+golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
+golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.3.0 h1:SrNbZl6ECOS1qFzgTdQfWXZM9XBkiA6tkFrH9YSTPHM=
+golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20160105164936-4f90aeace3a2/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 h1:VpOs+IwYnYBaFnrNAeB8UUWtL3vEUnzSCL1nVjPhqrw=
+gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
+gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637/go.mod h1:BHsqpu/nsuzkT5BpiH1EMZPLyqSMM8JbIavyFACoFNk=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
diff --git a/dnstester/machines/machines.go b/dnstester/machines/machines.go
new file mode 100644
index 0000000..90cd699
--- /dev/null
+++ b/dnstester/machines/machines.go
@@ -0,0 +1,244 @@
+package machines
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/netip"
+	"sync"
+
+	maas "github.com/maas/gomaasclient/client"
+	maasEntity "github.com/maas/gomaasclient/entity"
+	"github.com/miekg/dns"
+
+	"launchpad.net/maas-ci/system-tests/dnstester/query"
+)
+
+var (
+	ErrMachineNotDefined = errors.New("machine not defined")
+)
+
+type metaMachine struct {
+	SystemID string
+	Deployed bool
+	Machine  *maasEntity.Machine
+}
+
+type machineTracker struct {
+	machines *sync.Map
+}
+
+func newMachineTracker() *machineTracker {
+	return &machineTracker{
+		machines: &sync.Map{},
+	}
+}
+
+func (m *machineTracker) IsDefined(systemID string) bool {
+	_, ok := m.machines.Load(systemID)
+	return ok
+}
+
+func (m *machineTracker) IsDeployed(systemID string) bool {
+	machine, ok := m.machines.Load(systemID)
+	if !ok {
+		return false
+	}
+
+	metaMachine, ok := machine.(*metaMachine)
+	if !ok {
+		return false
+	}
+
+	return metaMachine.Deployed
+}
+
+func (m *machineTracker) DefineMachine(machine *metaMachine) {
+	m.machines.Store(machine.SystemID, machine)
+}
+
+func (m *machineTracker) IsReleased(systemID string) bool {
+	return !m.IsDeployed(systemID)
+}
+
+func (m *machineTracker) markStatus(systemID string, status bool) error {
+	machine, ok := m.machines.Load(systemID)
+	if !ok {
+		return ErrMachineNotDefined
+	}
+
+	metaMachine, ok := machine.(*metaMachine)
+
+	metaMachine.Deployed = status
+
+	m.machines.Store(systemID, machine)
+
+	return nil
+}
+
+func (m *machineTracker) MarkDeployed(systemID string) error {
+	return m.markStatus(systemID, true)
+}
+
+func (m *machineTracker) MarkReleased(systemID string) error {
+	return m.markStatus(systemID, false)
+}
+
+func (m *machineTracker) Machines() []*metaMachine {
+	var machines []*metaMachine
+
+	m.machines.Range(func(_, v any) bool {
+		machine, ok := v.(*metaMachine)
+		if !ok {
+			return false
+		}
+
+		machines = append(machines, machine)
+		return true
+	})
+
+	return machines
+}
+
+func (m *machineTracker) CleanUp(client *maas.Client) error {
+	var err error
+
+	m.machines.Range(func(_, v any) bool {
+		machine, ok := v.(*metaMachine)
+		if !ok {
+			return false
+		}
+
+		if machine.Deployed {
+			err = client.Machines.Release([]string{machine.SystemID}, "")
+			if err != nil {
+				return false
+			}
+			m.MarkReleased(machine.SystemID)
+		}
+		return true
+	})
+
+	return err
+}
+
+type machineToQuery struct {
+	Machine  *metaMachine
+	ShouldNX bool
+	Err      error
+}
+
+func deployOrRelease(ctx context.Context, client *maas.Client, tracker *machineTracker, wg *sync.WaitGroup) <-chan machineToQuery {
+	machineC := make(chan machineToQuery)
+
+	go func() {
+		machines := tracker.Machines()
+		for {
+			select {
+			case <-ctx.Done():
+				return
+			default:
+				for _, machine := range machines {
+					var toQuery machineToQuery
+
+					if tracker.IsDeployed(machine.SystemID) {
+						err := client.Machines.Release([]string{machine.SystemID}, "")
+						if err != nil {
+							toQuery.Err = err
+						} else {
+							toQuery.Machine = machine
+							toQuery.ShouldNX = true
+							err = tracker.MarkReleased(machine.SystemID)
+							if err != nil {
+								toQuery.Err = err
+							}
+						}
+					} else {
+						_, err := client.Machine.Deploy(machine.SystemID, &maasEntity.MachineDeployParams{
+							DistroSeries: "ubuntu/jammy",
+						})
+						if err != nil {
+							toQuery.Err = err
+						} else {
+							toQuery.Machine = machine
+							err = tracker.MarkDeployed(machine.SystemID)
+							if err != nil {
+								toQuery.Err = err
+							}
+						}
+					}
+
+					wg.Add(2) // fwd and rev query
+
+					machineC <- toQuery
+				}
+			}
+
+			wg.Wait()
+		}
+	}()
+
+	return machineC
+}
+
+func Run(ctx context.Context, systemIDs []string, zone string, maasClient *maas.Client, stats *query.Stats, querier *query.Querier) error {
+	tracker := newMachineTracker()
+
+	defer tracker.CleanUp(maasClient)
+
+	for _, systemID := range systemIDs {
+		machine, err := maasClient.Machine.Get(systemID)
+		if err != nil {
+			return err
+		}
+
+		meta := &metaMachine{
+			SystemID: systemID,
+			Machine:  machine,
+		}
+
+		tracker.DefineMachine(meta)
+	}
+
+	wg := &sync.WaitGroup{}
+
+	apiResponses := deployOrRelease(ctx, maasClient, tracker, wg)
+
+	for {
+		select {
+		case <-ctx.Done():
+			tracker.CleanUp(maasClient)
+			return nil
+		case machineToQuery := <-apiResponses:
+			if machineToQuery.Err != nil {
+				return machineToQuery.Err
+			}
+
+			for _, iface := range machineToQuery.Machine.Machine.InterfaceSet {
+				var rectype dns.Type
+
+				addrs := make([]string, len(iface.Links))
+
+				for i, link := range iface.Links {
+					addr, err := netip.ParseAddr(link.IPAddress)
+					if err != nil {
+						return err
+					}
+
+					if addr.Is4() {
+						rectype = dns.Type(dns.TypeA)
+					} else {
+						rectype = dns.Type(dns.TypeAAAA)
+					}
+
+					addrs[i] = link.IPAddress
+				}
+
+				name := fmt.Sprintf("%s.%s", machineToQuery.Machine.Machine.Hostname, zone)
+				go querier.QueryFor(ctx, stats, name, rectype, addrs, machineToQuery.ShouldNX, wg)
+				go querier.RevQueryFor(ctx, stats, name, addrs, machineToQuery.ShouldNX, wg)
+			}
+
+		}
+	}
+}
diff --git a/dnstester/main.go b/dnstester/main.go
new file mode 100644
index 0000000..e0d616a
--- /dev/null
+++ b/dnstester/main.go
@@ -0,0 +1,163 @@
+package main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"os"
+	"os/signal"
+	"strings"
+	"syscall"
+	"time"
+
+	"github.com/maas/gomaasclient/client"
+	"golang.org/x/sync/errgroup"
+
+	"launchpad.net/maas-ci/system-tests/dnstester/machines"
+	"launchpad.net/maas-ci/system-tests/dnstester/query"
+	"launchpad.net/maas-ci/system-tests/dnstester/records"
+)
+
+type options struct {
+	UseMachines      bool
+	UseRecords       bool
+	TestZone         string
+	SubnetsStr       string
+	subnets          []string
+	NumIPAddresses   int
+	NumAnswersPerRec int
+	SystemIDsStr     string
+	systemIDs        []string
+	Duration         time.Duration
+	OutStr           string
+	out              *os.File
+	MAASAPIKey       string
+	MAASURL          string
+	DNSServer        string
+}
+
+func (o *options) Subnets() []string {
+	if len(o.subnets) > 0 {
+		return o.subnets
+	}
+
+	o.subnets = strings.Split(o.SubnetsStr, ",")
+	return o.subnets
+}
+
+func (o *options) SystemIDs() []string {
+	if len(o.systemIDs) > 0 {
+		return o.systemIDs
+	}
+
+	o.systemIDs = strings.Split(o.SystemIDsStr, ",")
+	return o.systemIDs
+}
+
+func (o *options) Out() (*os.File, error) {
+	if o.out != nil {
+		return o.out, nil
+	}
+
+	var err error
+	if o.OutStr == "" {
+		o.out = os.Stdout
+	} else {
+		o.out, err = os.OpenFile(o.OutStr, os.O_CREATE|os.O_RDWR, 0644)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return o.out, nil
+}
+
+func run() int {
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	sigC := make(chan os.Signal, 2)
+	signal.Notify(sigC, syscall.SIGINT, syscall.SIGTERM)
+
+	opts := &options{}
+	flag.BoolVar(&opts.UseMachines, "machines", false, "deploys and releases machines to test DNS when true")
+	flag.BoolVar(&opts.UseRecords, "records", false, "creates and deletes DNS records to test DNS when true")
+	flag.StringVar(&opts.TestZone, "zone", "maas", "name of the DNS zone (i.e MAAS domain) to use in tests")
+	flag.StringVar(&opts.SubnetsStr, "subnets", "", "comma-separated list of subnets to create IPs from for DNS records tests")
+	flag.IntVar(&opts.NumIPAddresses, "num-ips", 4, "number of IPs to generate per-subnet")
+	flag.IntVar(&opts.NumAnswersPerRec, "num-answers", 2, "number of answers to assign to a record for DNS records tests")
+	flag.StringVar(&opts.SystemIDsStr, "system-ids", "", "comma-separated list of machine system-ids to deploy/release in machine tests")
+	flag.DurationVar(&opts.Duration, "duration", 30*time.Second, "duration of tests")
+	flag.StringVar(&opts.OutStr, "out", "", "name of file to output to, uses stdout when not set")
+	flag.StringVar(&opts.MAASAPIKey, "apikey", "", "MAAS API key to use")
+	flag.StringVar(&opts.MAASURL, "maas-url", "", "MAAS URL to use")
+	flag.StringVar(&opts.DNSServer, "dns", "", "DNS Server <host>:<ip> to query")
+
+	flag.Parse()
+
+	out, err := opts.Out()
+	if err != nil {
+		fmt.Println(err)
+		return 1
+	}
+
+	stats := query.NewStats(out)
+	defer stats.Results()
+
+	g, ctx := errgroup.WithContext(ctx)
+
+	querier, err := query.NewQuerier(ctx, opts.DNSServer)
+	if err != nil {
+		fmt.Println(err)
+		return 1
+	}
+
+	maasClient, err := client.GetClient(opts.MAASURL, opts.MAASAPIKey, "2.0")
+	if err != nil {
+		fmt.Println(err)
+		return 1
+	}
+
+	if opts.UseMachines {
+		g.Go(func() error {
+			return machines.Run(ctx, opts.SystemIDs(), opts.TestZone, maasClient, stats, querier)
+		})
+	}
+
+	if opts.UseRecords {
+		g.Go(func() error {
+			return records.Run(ctx, opts.NumIPAddresses, opts.NumAnswersPerRec, opts.TestZone, opts.Subnets(), stats, maasClient, querier)
+		})
+	}
+
+	errC := make(chan error)
+
+	go func() {
+		if err := g.Wait(); err != nil {
+			errC <- err
+		} else {
+			close(errC)
+		}
+	}()
+
+	select {
+	case <-sigC:
+		cancel()
+	case <-time.After(opts.Duration):
+		cancel()
+	case err := <-errC:
+		fmt.Println(err)
+		return 1
+	}
+
+	if err = <-errC; err != nil {
+		fmt.Println(err)
+		return 1
+	}
+
+	return 0
+}
+
+func main() {
+	os.Exit(run())
+}
diff --git a/dnstester/query/query.go b/dnstester/query/query.go
new file mode 100644
index 0000000..044548d
--- /dev/null
+++ b/dnstester/query/query.go
@@ -0,0 +1,110 @@
+package query
+
+import (
+	"context"
+	"sync"
+	"time"
+
+	"github.com/miekg/dns"
+)
+
+const (
+	maxTries = 10
+	interval = time.Second
+)
+
+type Querier struct {
+	client *dns.Client
+	addr   string
+}
+
+func NewQuerier(ctx context.Context, addr string) (*Querier, error) {
+	q := &Querier{
+		client: &dns.Client{},
+		addr:   addr,
+	}
+
+	return q, nil
+}
+
+func (q *Querier) QueryFor(ctx context.Context, stats *Stats, name string, rectype dns.Type, answers []string, shouldNX bool, wg *sync.WaitGroup) {
+	defer wg.Done()
+
+	name = dns.Fqdn(name)
+
+	msg := &dns.Msg{
+		Question: []dns.Question{
+			{
+				Name:   name,
+				Qtype:  uint16(rectype),
+				Qclass: dns.ClassINET,
+			},
+		},
+	}
+
+	for tries := 0; tries < maxTries; tries++ {
+		resp, _, err := q.client.ExchangeContext(ctx, msg, q.addr)
+		if err != nil {
+			return
+		}
+
+		var foundCount int
+
+		for _, exp := range answers {
+			for i, ans := range resp.Answer {
+				var ansStr string
+
+				comp := exp
+
+				switch answer := ans.(type) {
+				case *dns.A:
+					ansStr = answer.A.String()
+				case *dns.AAAA:
+					ansStr = answer.AAAA.String()
+				case *dns.PTR:
+					ansStr = answer.Ptr
+					comp = dns.Fqdn(comp)
+				}
+
+				if ansStr == comp {
+					if !shouldNX {
+						foundCount++
+						stats.AddHit()
+						break
+					}
+				} else if i == len(resp.Answer)-1 {
+					if shouldNX {
+						foundCount++
+						stats.AddHit()
+					}
+				}
+			}
+		}
+
+		if foundCount == len(answers) {
+			return
+		}
+
+		time.Sleep(time.Duration(tries) * interval)
+	}
+	stats.AddMiss()
+}
+
+func (q *Querier) RevQueryFor(ctx context.Context, stats *Stats, name string, answers []string, shouldNX bool, wg *sync.WaitGroup) {
+	defer wg.Done()
+
+	childWg := &sync.WaitGroup{}
+
+	childWg.Add(len(answers))
+
+	for _, answer := range answers {
+		label, err := dns.ReverseAddr(answer)
+		if err != nil {
+			return
+		}
+
+		go q.QueryFor(ctx, stats, label, dns.Type(dns.TypePTR), []string{name}, shouldNX, childWg)
+	}
+
+	childWg.Wait()
+}
diff --git a/dnstester/query/stats.go b/dnstester/query/stats.go
new file mode 100644
index 0000000..b0d07b7
--- /dev/null
+++ b/dnstester/query/stats.go
@@ -0,0 +1,42 @@
+package query
+
+import (
+	"encoding/json"
+	"io"
+	"sync/atomic"
+)
+
+type Stats struct {
+	out    io.WriteCloser
+	Hits   int64
+	Misses int64
+	Total  int64
+}
+
+func NewStats(out io.WriteCloser) *Stats {
+	return &Stats{
+		out: out,
+	}
+}
+
+func (s *Stats) AddHit() {
+	atomic.AddInt64(&s.Hits, 1)
+	atomic.AddInt64(&s.Total, 1)
+}
+
+func (s *Stats) AddMiss() {
+	atomic.AddInt64(&s.Misses, 1)
+	atomic.AddInt64(&s.Total, 1)
+}
+
+func (s *Stats) Results() {
+	defer s.out.Close()
+
+	output := map[string]int64{
+		"hits":   atomic.LoadInt64(&s.Hits),
+		"misses": atomic.LoadInt64(&s.Misses),
+		"total":  atomic.LoadInt64(&s.Total),
+	}
+
+	json.NewEncoder(s.out).Encode(output)
+}
diff --git a/dnstester/records/records.go b/dnstester/records/records.go
new file mode 100644
index 0000000..b5b1b9d
--- /dev/null
+++ b/dnstester/records/records.go
@@ -0,0 +1,259 @@
+package records
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/netip"
+	"strings"
+	"sync"
+
+	maas "github.com/maas/gomaasclient/client"
+	maasEntity "github.com/maas/gomaasclient/entity"
+	"github.com/miekg/dns"
+
+	"launchpad.net/maas-ci/system-tests/dnstester/query"
+)
+
+const (
+	testTTL = 30
+)
+
+var (
+	ErrUnknownRecord = errors.New("unknown record")
+)
+
+type metaRecord struct {
+	Name    string
+	Answers []string
+	Type    dns.Type
+	Created bool
+	MAASID  int
+}
+
+type recordTracker struct {
+	records *sync.Map
+}
+
+func newRecordTracker() *recordTracker {
+	return &recordTracker{
+		records: &sync.Map{},
+	}
+}
+
+func (r *recordTracker) IsDefined(name string) bool {
+	_, ok := r.records.Load(name)
+	return ok
+}
+
+func (r *recordTracker) IsCreated(name string) bool {
+	recI, ok := r.records.Load(name)
+	if !ok {
+		return false
+	}
+
+	rec := recI.(*metaRecord)
+
+	return rec.Created
+}
+
+func (r *recordTracker) IsDeleted(name string) bool {
+	return !r.IsCreated(name)
+}
+
+func (r *recordTracker) DefineRecord(rec *metaRecord) {
+	r.records.Store(rec.Name, rec)
+}
+
+func (r *recordTracker) markStatus(name string, status bool, id ...int) error {
+	recI, ok := r.records.Load(name)
+	if !ok {
+		return ErrUnknownRecord
+	}
+
+	rec := recI.(*metaRecord)
+
+	rec.Created = status
+
+	if len(id) > 0 {
+		rec.MAASID = id[0]
+	} else {
+		rec.MAASID = -1
+	}
+
+	r.records.Store(name, rec)
+
+	return nil
+}
+
+func (r *recordTracker) MarkCreated(name string, id int) error {
+	return r.markStatus(name, true, id)
+}
+
+func (r *recordTracker) MarkDeleted(name string) error {
+	return r.markStatus(name, false)
+}
+
+func (r *recordTracker) Records() []*metaRecord {
+	var res []*metaRecord
+
+	r.records.Range(func(_, v any) bool {
+		rec, ok := v.(*metaRecord)
+		if !ok {
+			return false
+		}
+
+		res = append(res, rec)
+
+		return true
+	})
+
+	return res
+}
+
+func (r *recordTracker) CleanUp(client *maas.Client) error {
+	var err error
+
+	r.records.Range(func(_, v any) bool {
+		rec, ok := v.(*metaRecord)
+		if !ok {
+			return false
+		}
+		if rec.Created {
+			err = client.DNSResource.Delete(rec.MAASID)
+			if err != nil {
+				return false
+			}
+			r.MarkDeleted(rec.Name)
+		}
+		return true
+	})
+
+	return err
+}
+
+type recToQuery struct {
+	Record   *metaRecord
+	ShouldNX bool
+	Err      error
+}
+
+func createOrDelete(ctx context.Context, client *maas.Client, tracker *recordTracker) ([]recToQuery, error) {
+	records := tracker.Records()
+
+	res := make([]recToQuery, len(records))
+
+	select {
+	case <-ctx.Done():
+		return nil, nil
+	default:
+		for i, record := range records {
+			var toQuery recToQuery
+
+			if tracker.IsCreated(record.Name) {
+				err := client.DNSResource.Delete(record.MAASID)
+				if err != nil {
+					return nil, err
+				} else {
+					toQuery.Record = record
+					toQuery.ShouldNX = true
+					err = tracker.MarkDeleted(record.Name)
+					if err != nil {
+						return nil, err
+					}
+				}
+			} else {
+				rec, err := client.DNSResources.Create(&maasEntity.DNSResourceParams{
+					FQDN:        record.Name,
+					IPAddresses: strings.Join(record.Answers, " "),
+					AddressTTL:  testTTL,
+				})
+				if err != nil {
+					return nil, err
+				} else {
+					toQuery.Record = record
+					err = tracker.MarkCreated(record.Name, rec.ID)
+					if err != nil {
+						return nil, err
+					}
+				}
+			}
+
+			res[i] = toQuery
+		}
+	}
+
+	return res, nil
+}
+
+func Run(
+	ctx context.Context,
+	numIPs int,
+	numAnswersPerRec int,
+	zone string,
+	subnets []string,
+	stats *query.Stats,
+	maasClient *maas.Client,
+	querier *query.Querier,
+) error {
+	tracker := newRecordTracker()
+
+	defer tracker.CleanUp(maasClient)
+
+	ips := make([]netip.Addr, len(subnets)*numIPs)
+
+	for i, subnet := range subnets {
+		network, err := netip.ParsePrefix(subnet)
+		if err != nil {
+			return err
+		}
+
+		addr := network.Addr()
+
+		for j := 0 + (i * numIPs); j < numIPs+(i*numIPs); j++ {
+			ips[j] = addr
+			addr = addr.Next()
+		}
+	}
+
+	currRec := &metaRecord{
+		Name: fmt.Sprintf("test0.%s", zone),
+	}
+
+	recIdx := 0
+	for _, ip := range ips {
+		currRec.Answers = append(currRec.Answers, ip.String())
+
+		if len(currRec.Answers) == numAnswersPerRec {
+			recIdx++
+			tracker.DefineRecord(currRec)
+			currRec = &metaRecord{
+				Name: fmt.Sprintf("test%d.%s", recIdx, zone),
+			}
+		}
+	}
+
+	for {
+		select {
+		case <-ctx.Done():
+			tracker.CleanUp(maasClient)
+			return nil
+		default:
+			wg := &sync.WaitGroup{}
+
+			apiResponses, err := createOrDelete(ctx, maasClient, tracker)
+			if err != nil {
+				return err
+			}
+
+			wg.Add(len(apiResponses) * 2)
+
+			for _, rec := range apiResponses {
+				go querier.QueryFor(ctx, stats, rec.Record.Name, dns.Type(dns.TypeA), rec.Record.Answers, rec.ShouldNX, wg)
+				go querier.RevQueryFor(ctx, stats, rec.Record.Name, rec.Record.Answers, rec.ShouldNX, wg)
+			}
+
+			wg.Wait()
+		}
+	}
+}
diff --git a/pyproject.toml b/pyproject.toml
index ca3654e..b4f3066 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -2,6 +2,7 @@
 addopts = "--strict-markers --durations=10"
 markers = [
     "skip_if_ansible_playbooks_unconfigured", # Skips tests if Ansible playbooks aren't present.
+    "skip_if_dns_unconfigured", # skips tests if DNS configuration isn't present
     "skip_if_installed_from_snap", # Skips tests if MAAS is installed as a snap.
     "skip_if_installed_from_deb_package" # Skips tests if MAAS is installed from package.
 ]
diff --git a/systemtests/conftest.py b/systemtests/conftest.py
index fa1724c..d67ce82 100644
--- a/systemtests/conftest.py
+++ b/systemtests/conftest.py
@@ -36,6 +36,7 @@ from .fixtures import (
     MAAS_VERSION_KEY,
     ansible_main,
     build_container,
+    dns_tester,
     logstream,
     maas_api_client,
     maas_client_container,
@@ -45,6 +46,7 @@ from .fixtures import (
     maas_region,
     pool,
     skip_if_ansible_playbooks_unconfigured,
+    skip_if_dns_unconfigured,
     skip_if_installed_from_deb_package,
     skip_if_installed_from_snap,
     ssh_key,
@@ -80,6 +82,7 @@ __all__ = [
     "authenticated_admin",
     "build_container",
     "configured_maas",
+    "dns_tester",
     "import_images_and_wait_until_synced",
     "logstream",
     "maas_api_client",
@@ -94,6 +97,7 @@ __all__ = [
     "ready_remote_maas",
     "ssh_key",
     "skip_if_ansible_playbooks_unconfigured",
+    "skip_if_dns_unconfigured",
     "skip_if_installed_from_deb_package",
     "skip_if_installed_from_snap",
     "tag_all",
diff --git a/systemtests/dns_tests/test_dns.py b/systemtests/dns_tests/test_dns.py
new file mode 100644
index 0000000..b696d49
--- /dev/null
+++ b/systemtests/dns_tests/test_dns.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from systemtests.api import AuthenticatedAPIClient
+from systemtests.dnstester import DNSTester
+
+
+@pytest.mark.skip_if_dns_unconfigured("Needs DNS configuration")
+class TestDNS:
+    def test_resource_records_dns(self, dns_tester: DNSTester, maas_api_client: AuthenticatedAPIClient):
+        subnets = maas_api_client.list_subnets()
+        cidrs = [ subnet["cidr"] for subnet in subnets ]
+        proc, out = dns_tester.run_records(cidrs)
+        assert proc.return_code == 0
+        assert out["misses"] == 0, f"results: {out}"
+
+    def test_machine_dns(self, dns_tester: DNSTester, maas_api_client: AuthenticatedAPIClient):
+        machines = maas_api_client.list_machines(status="ready")
+        system_ids = [ machine["system_id"] for machine in machines ]
+        proc, out = dns_tester.run_machines(system_ids)
+        assert proc.return_code == 0
+        assert out["misses"] == 0, f"results: {out}"
diff --git a/systemtests/dnstester.py b/systemtests/dnstester.py
new file mode 100644
index 0000000..ecab6c2
--- /dev/null
+++ b/systemtests/dnstester.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+from typing import Any, Optional, TYPE_CHECKING
+import subprocess
+
+from .subprocess import run_with_logging
+
+if TYPE_CHECKING:
+    import logging
+
+
+class DNSTester:
+    executable = "/tmp/dnstester"
+    src_dir = "dnstester/"
+
+    def __init__(
+        self,
+        api_key: str,
+        maas_url: str,
+        dns_server: str,
+        dns_zone: Optional[str] = "maas",
+        num_ips: Optional[int] = 4,
+        num_answers: Optional[int] = 2,
+        duration: Optional[int] = None,
+        logger: Optional[logging.Logger] = None,
+    ):
+        self._api_key = api_key
+        self._maas_url = maas_url
+        self._dns_server = dns_server
+        self.logger = logger
+        self._duration = duration
+        self._dns_zone = dns_zone
+        self._num_ips = num_ips
+        self._num_answers = num_answers
+        self._abs_path_to_src = str((Path(__file__).parent.parent / Path(self.src_dir)).absolute())
+
+    @property
+    def executable_exists(self):
+        try:
+            os.stat(self.executable)
+        except FileNotFoundError:
+            return False
+        else:
+            return True
+
+    def build(self):
+        cmd = ["go", "build", "-o", self.executable, self._abs_path_to_src]
+        proc = subprocess.run(cmd, cwd=self._abs_path_to_src)
+        assert proc.returncode == 0
+
+    def _run(self, use_records: bool = False, use_machines: bool =False, **kwargs: dict[str, Any]):
+        cmd = [
+            self.executable,
+            f"-apikey={self._api_key}",
+            f"-maas-url={self._maas_url}",
+            f"-dns={self._dns_server}",
+            f"-zone={self._dns_zone}",
+        ]
+
+        if use_records:
+            cmd.extend(["-records=1", f"-num-ips={self._num_ips}", f"-num-answers={self._num_answers}", f"-subnets={','.join(kwargs['subnets'])}"])
+
+        if use_machines:
+            cmd.extend(["-machines=1", f"-system-ids={','.join(kwargs['system_ids'])}"])
+
+        if self._duration:
+            cmd.extend([f"-duration={self._duration}s"])
+
+        proc = run_with_logging(cmd, self.logger)
+        return (proc, json.dumps(proc.stdout))
+
+    def run_records(self, subnets):
+        return self._run(use_records=True, subnets=subnets)
+
+    def run_machines(self, system_ids):
+        return self._run(use_machines=True, system_ids=system_ids)
diff --git a/systemtests/fixtures.py b/systemtests/fixtures.py
index 64d3974..15a9980 100644
--- a/systemtests/fixtures.py
+++ b/systemtests/fixtures.py
@@ -15,6 +15,7 @@ from pytest_steps import one_fixture_per_step
 from .ansible import AnsibleMain
 from .api import AuthenticatedAPIClient, UnauthenticatedMAASAPIClient
 from .config import ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_USER
+from .dnstester import DNSTester
 from .lxd import Instance, get_lxd
 from .o11y import is_o11y_enabled, setup_o11y
 from .region import MAASRegion
@@ -629,6 +630,16 @@ def skip_if_ansible_playbooks_unconfigured(
             pytest.skip(reason)
 
 
+@pytest.fixture(autouse=True)
+def skip_if_dns_unconfigured(request: Any, config: dict[str, Any]) -> None:
+    """Skip tests that require DNS configuration."""
+    marker = request.node.get_closest_marker("skip_if_dns_unconfigured")
+    if marker:
+        reason = marker.args[0]
+        if "dns_tests" not in config:
+            pytest.skip(reason)
+
+
 @pytest.fixture(scope="session")
 def ssh_key(authenticated_admin: AuthenticatedAPIClient) -> Iterator[paramiko.PKey]:
     """Generate an SSH key to access deployed machines."""
@@ -713,3 +724,24 @@ def ensure_host_ip_mapping(instance: Instance, hostname: str, ip: str) -> None:
     if line in content:
         return
     hosts_file.write(content + line)
+
+
+@pytest.fixture()
+def dns_tester(maas_credentials: dict[str, str], config: dict[str, Any], testlog: Logger) -> Iterator[DNSTester]:
+    dns_config = config["dns_tests"]
+
+    tester = DNSTester(
+        maas_credentials["api_key"],
+        maas_credentials["region_url"],
+        dns_config["server"],
+        dns_zone=dns_config.get("zone", "maas"),
+        num_ips=dns_config.get("num_ips", 4),
+        num_answers=dns_config.get("num_answers", 2),
+        duration=dns_config.get("duration"),
+        logger=testlog,
+    )
+
+    if not tester.executable_exists:
+        tester.build()
+
+    yield tester
diff --git a/tox.ini b/tox.ini
index 34ab368..d23e168 100644
--- a/tox.ini
+++ b/tox.ini
@@ -26,7 +26,7 @@ passenv =
   MAAS_SYSTEMTESTS_CLIENT_CONTAINER
   MAAS_SYSTEMTESTS_LXD_PROFILE
 
-[testenv:{env_builder,collect_sos_report,general_tests,ansible_tests}]
+[testenv:{env_builder,collect_sos_report,general_tests,ansible_tests,dns_tests}]
 passenv = {[base]passenv}
 
 [testenv:cog]
-- 
Mailing list: https://launchpad.net/~sts-sponsors
Post to     : sts-sponsors@lists.launchpad.net
Unsubscribe : https://launchpad.net/~sts-sponsors
More help   : https://help.launchpad.net/ListHelp

Reply via email to