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