sts-sponsors team mailing list archive
-
sts-sponsors team
-
Mailing list archive
-
Message #08711
[Merge] ~cgrabowski/maas-ci/+git/system-tests:add_dns_tests into ~maas-committers/maas-ci/+git/system-tests:master
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]
Follow ups