zotregistry.io/zot@v1.4.4-0.20231124084042-02a8ed785457/cmd/zb/perf.go (about)

     1  package main
     2  
     3  import (
     4  	crand "crypto/rand"
     5  	"crypto/tls"
     6  	"fmt"
     7  	"log"
     8  	"math/big"
     9  	"net"
    10  	"net/http"
    11  	urlparser "net/url"
    12  	"os"
    13  	"path"
    14  	"sort"
    15  	"strings"
    16  	"sync"
    17  	"text/tabwriter"
    18  	"time"
    19  
    20  	jsoniter "github.com/json-iterator/go"
    21  	godigest "github.com/opencontainers/go-digest"
    22  	"gopkg.in/resty.v1"
    23  
    24  	"zotregistry.io/zot/pkg/api/constants"
    25  )
    26  
    27  const (
    28  	KiB                  = 1 * 1024
    29  	MiB                  = 1 * KiB * 1024
    30  	GiB                  = 1 * MiB * 1024
    31  	maxSize              = 1 * GiB // 1GiB
    32  	defaultDirPerms      = 0o700
    33  	defaultFilePerms     = 0o600
    34  	defaultSchemaVersion = 2
    35  	smallBlob            = 1 * MiB
    36  	mediumBlob           = 10 * MiB
    37  	largeBlob            = 100 * MiB
    38  	cicdFmt              = "ci-cd"
    39  	secureProtocol       = "https"
    40  	httpKeepAlive        = 30 * time.Second
    41  	maxSourceIPs         = 1000
    42  	httpTimeout          = 30 * time.Second
    43  	TLSHandshakeTimeout  = 10 * time.Second
    44  )
    45  
    46  //nolint:gochecknoglobals
    47  var blobHash map[string]godigest.Digest = map[string]godigest.Digest{}
    48  
    49  //nolint:gochecknoglobals // used only in this test
    50  var statusRequests sync.Map
    51  
    52  func setup(workingDir string) {
    53  	_ = os.MkdirAll(workingDir, defaultDirPerms)
    54  
    55  	const multiplier = 10
    56  
    57  	const rndPageSize = 4 * KiB
    58  
    59  	for size := 1 * MiB; size < maxSize; size *= multiplier {
    60  		fname := path.Join(workingDir, fmt.Sprintf("%d.blob", size))
    61  
    62  		fhandle, err := os.OpenFile(fname, os.O_RDWR|os.O_CREATE|os.O_TRUNC, defaultFilePerms)
    63  		if err != nil {
    64  			log.Fatal(err)
    65  		}
    66  
    67  		err = fhandle.Truncate(int64(size))
    68  		if err != nil {
    69  			log.Fatal(err)
    70  		}
    71  
    72  		_, err = fhandle.Seek(0, 0)
    73  		if err != nil {
    74  			log.Fatal(err)
    75  		}
    76  
    77  		// write a random first page so every test run has different blob content
    78  		rnd := make([]byte, rndPageSize)
    79  		if _, err := crand.Read(rnd); err != nil {
    80  			log.Fatal(err)
    81  		}
    82  
    83  		if _, err := fhandle.Write(rnd); err != nil {
    84  			log.Fatal(err)
    85  		}
    86  
    87  		if _, err := fhandle.Seek(0, 0); err != nil {
    88  			log.Fatal(err)
    89  		}
    90  
    91  		fhandle.Close() // should flush the write
    92  
    93  		// pre-compute the SHA256
    94  		fhandle, err = os.OpenFile(fname, os.O_RDONLY, defaultFilePerms)
    95  		if err != nil {
    96  			log.Fatal(err)
    97  		}
    98  
    99  		defer fhandle.Close()
   100  
   101  		digest, err := godigest.FromReader(fhandle)
   102  		if err != nil {
   103  			log.Fatal(err) //nolint:gocritic // file closed on exit
   104  		}
   105  
   106  		blobHash[fname] = digest
   107  	}
   108  }
   109  
   110  func teardown(workingDir string) {
   111  	_ = os.RemoveAll(workingDir)
   112  }
   113  
   114  // statistics handling.
   115  
   116  type Durations []time.Duration
   117  
   118  func (a Durations) Len() int           { return len(a) }
   119  func (a Durations) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
   120  func (a Durations) Less(i, j int) bool { return a[i] < a[j] }
   121  
   122  type statsSummary struct {
   123  	latencies            []time.Duration
   124  	name                 string
   125  	min, max, total      time.Duration
   126  	statusHist           map[string]int
   127  	rps                  float32
   128  	mixedSize, mixedType bool
   129  	errors               int
   130  }
   131  
   132  func newStatsSummary(name string) statsSummary {
   133  	summary := statsSummary{
   134  		name:       name,
   135  		min:        -1,
   136  		max:        -1,
   137  		statusHist: make(map[string]int),
   138  		mixedSize:  false,
   139  		mixedType:  false,
   140  	}
   141  
   142  	return summary
   143  }
   144  
   145  type statsRecord struct {
   146  	latency    time.Duration
   147  	statusCode int
   148  	isConnFail bool
   149  	isErr      bool
   150  }
   151  
   152  func updateStats(summary *statsSummary, record statsRecord) {
   153  	if record.isConnFail || record.isErr {
   154  		summary.errors++
   155  	}
   156  
   157  	if summary.min < 0 || record.latency < summary.min {
   158  		summary.min = record.latency
   159  	}
   160  
   161  	if summary.max < 0 || record.latency > summary.max {
   162  		summary.max = record.latency
   163  	}
   164  
   165  	// 2xx
   166  	if record.statusCode >= http.StatusOK &&
   167  		record.statusCode <= http.StatusAccepted {
   168  		summary.statusHist["2xx"]++
   169  	}
   170  
   171  	// 3xx
   172  	if record.statusCode >= http.StatusMultipleChoices &&
   173  		record.statusCode <= http.StatusPermanentRedirect {
   174  		summary.statusHist["3xx"]++
   175  	}
   176  
   177  	// 4xx
   178  	if record.statusCode >= http.StatusBadRequest &&
   179  		record.statusCode <= http.StatusUnavailableForLegalReasons {
   180  		summary.statusHist["4xx"]++
   181  	}
   182  
   183  	// 5xx
   184  	if record.statusCode >= http.StatusInternalServerError &&
   185  		record.statusCode <= http.StatusNetworkAuthenticationRequired {
   186  		summary.statusHist["5xx"]++
   187  	}
   188  
   189  	summary.latencies = append(summary.latencies, record.latency)
   190  }
   191  
   192  type cicdTestSummary struct {
   193  	Name  string      `json:"name"`
   194  	Unit  string      `json:"unit"`
   195  	Value interface{} `json:"value"`
   196  	Range string      `json:"range,omitempty"`
   197  }
   198  
   199  type manifestStruct struct {
   200  	manifestHash       map[string]string
   201  	manifestBySizeHash map[int](map[string]string)
   202  }
   203  
   204  //nolint:gochecknoglobals // used only in this test
   205  var cicdSummary = []cicdTestSummary{}
   206  
   207  func printStats(requests int, summary *statsSummary, outFmt string) {
   208  	log.Printf("============\n")
   209  	log.Printf("Test name:\t%s", summary.name)
   210  	log.Printf("Time taken for tests:\t%v", summary.total)
   211  	log.Printf("Complete requests:\t%v", requests-summary.errors)
   212  	log.Printf("Failed requests:\t%v", summary.errors)
   213  	log.Printf("Requests per second:\t%v", summary.rps)
   214  	log.Printf("\n")
   215  
   216  	if summary.mixedSize {
   217  		current := loadOrStore(&statusRequests, "1MB", 0)
   218  		log.Printf("1MB:\t%v", current)
   219  
   220  		current = loadOrStore(&statusRequests, "10MB", 0)
   221  		log.Printf("10MB:\t%v", current)
   222  
   223  		current = loadOrStore(&statusRequests, "100MB", 0)
   224  		log.Printf("100MB:\t%v", current)
   225  
   226  		log.Printf("\n")
   227  	}
   228  
   229  	if summary.mixedType {
   230  		pull := loadOrStore(&statusRequests, "Pull", 0)
   231  		log.Printf("Pull:\t%v", pull)
   232  
   233  		push := loadOrStore(&statusRequests, "Push", 0)
   234  		log.Printf("Push:\t%v", push)
   235  
   236  		log.Printf("\n")
   237  	}
   238  
   239  	for k, v := range summary.statusHist {
   240  		log.Printf("%s responses:\t%v", k, v)
   241  	}
   242  
   243  	log.Printf("\n")
   244  	log.Printf("min: %v", summary.min)
   245  	log.Printf("max: %v", summary.max)
   246  	log.Printf("%s:\t%v", "p50", summary.latencies[requests/2])
   247  	log.Printf("%s:\t%v", "p75", summary.latencies[requests*3/4])
   248  	log.Printf("%s:\t%v", "p90", summary.latencies[requests*9/10])
   249  	log.Printf("%s:\t%v", "p99", summary.latencies[requests*99/100])
   250  	log.Printf("\n")
   251  
   252  	// ci/cd
   253  	if outFmt == cicdFmt {
   254  		cicdSummary = append(cicdSummary,
   255  			cicdTestSummary{
   256  				Name:  summary.name,
   257  				Unit:  "requests per sec",
   258  				Value: summary.rps,
   259  				Range: "3",
   260  			},
   261  		)
   262  	}
   263  }
   264  
   265  // test suites/funcs.
   266  
   267  type testFunc func(
   268  	workdir, url, repo string,
   269  	requests int,
   270  	config testConfig,
   271  	statsCh chan statsRecord,
   272  	client *resty.Client,
   273  	skipCleanup bool,
   274  ) error
   275  
   276  //nolint:gosec
   277  func GetCatalog(
   278  	workdir, url, repo string,
   279  	requests int,
   280  	config testConfig,
   281  	statsCh chan statsRecord,
   282  	client *resty.Client,
   283  	skipCleanup bool,
   284  ) error {
   285  	var repos []string
   286  
   287  	var err error
   288  
   289  	statusRequests = sync.Map{}
   290  
   291  	for count := 0; count < requests; count++ {
   292  		// Push random blob
   293  		_, repos, err = pushMonolithImage(workdir, url, repo, repos, config, client)
   294  		if err != nil {
   295  			return err
   296  		}
   297  	}
   298  
   299  	for count := 0; count < requests; count++ {
   300  		func() {
   301  			start := time.Now()
   302  
   303  			var isConnFail, isErr bool
   304  
   305  			var statusCode int
   306  
   307  			var latency time.Duration
   308  
   309  			defer func() {
   310  				// send a stats record
   311  				statsCh <- statsRecord{
   312  					latency:    latency,
   313  					statusCode: statusCode,
   314  					isConnFail: isConnFail,
   315  					isErr:      isErr,
   316  				}
   317  			}()
   318  
   319  			// send request and get response
   320  			resp, err := client.R().Get(url + constants.RoutePrefix + constants.ExtCatalogPrefix)
   321  
   322  			latency = time.Since(start)
   323  
   324  			if err != nil {
   325  				isConnFail = true
   326  
   327  				return
   328  			}
   329  
   330  			// request specific check
   331  			statusCode = resp.StatusCode()
   332  			if statusCode != http.StatusOK {
   333  				isErr = true
   334  
   335  				return
   336  			}
   337  		}()
   338  	}
   339  
   340  	// clean up
   341  	if !skipCleanup {
   342  		err = deleteTestRepo(repos, url, client)
   343  		if err != nil {
   344  			return err
   345  		}
   346  	}
   347  
   348  	return nil
   349  }
   350  
   351  func PushMonolithStreamed(
   352  	workdir, url, trepo string,
   353  	requests int,
   354  	config testConfig,
   355  	statsCh chan statsRecord,
   356  	client *resty.Client,
   357  	skipCleanup bool,
   358  ) error {
   359  	var repos []string
   360  
   361  	if config.mixedSize {
   362  		statusRequests = sync.Map{}
   363  	}
   364  
   365  	for count := 0; count < requests; count++ {
   366  		repos = pushMonolithAndCollect(workdir, url, trepo, count,
   367  			repos, config, client, statsCh)
   368  	}
   369  
   370  	// clean up
   371  	if !skipCleanup {
   372  		err := deleteTestRepo(repos, url, client)
   373  		if err != nil {
   374  			return err
   375  		}
   376  	}
   377  
   378  	return nil
   379  }
   380  
   381  func PushChunkStreamed(
   382  	workdir, url, trepo string,
   383  	requests int,
   384  	config testConfig,
   385  	statsCh chan statsRecord,
   386  	client *resty.Client,
   387  	skipCleanup bool,
   388  ) error {
   389  	var repos []string
   390  
   391  	if config.mixedSize {
   392  		statusRequests = sync.Map{}
   393  	}
   394  
   395  	for count := 0; count < requests; count++ {
   396  		repos = pushChunkAndCollect(workdir, url, trepo, count,
   397  			repos, config, client, statsCh)
   398  	}
   399  
   400  	// clean up
   401  	if !skipCleanup {
   402  		err := deleteTestRepo(repos, url, client)
   403  		if err != nil {
   404  			return err
   405  		}
   406  	}
   407  
   408  	return nil
   409  }
   410  
   411  func Pull(
   412  	workdir, url, trepo string,
   413  	requests int,
   414  	config testConfig,
   415  	statsCh chan statsRecord,
   416  	client *resty.Client,
   417  	skipCleanup bool,
   418  ) error {
   419  	var repos []string
   420  
   421  	var manifestHash map[string]string
   422  
   423  	manifestBySizeHash := make(map[int](map[string]string))
   424  
   425  	if config.mixedSize {
   426  		statusRequests = sync.Map{}
   427  	}
   428  
   429  	if config.mixedSize {
   430  		var manifestBySize map[string]string
   431  
   432  		smallSizeIdx := 0
   433  		mediumSizeIdx := 1
   434  		largeSizeIdx := 2
   435  
   436  		config.size = smallBlob
   437  
   438  		// Push small blob
   439  		manifestBySize, repos, err := pushMonolithImage(workdir, url, trepo, repos, config, client)
   440  		if err != nil {
   441  			return err
   442  		}
   443  
   444  		manifestBySizeHash[smallSizeIdx] = manifestBySize
   445  
   446  		config.size = mediumBlob
   447  
   448  		// Push medium blob
   449  		manifestBySize, repos, err = pushMonolithImage(workdir, url, trepo, repos, config, client)
   450  		if err != nil {
   451  			return err
   452  		}
   453  
   454  		manifestBySizeHash[mediumSizeIdx] = manifestBySize
   455  
   456  		config.size = largeBlob
   457  
   458  		// Push large blob
   459  		//nolint: ineffassign, staticcheck, wastedassign
   460  		manifestBySize, repos, err = pushMonolithImage(workdir, url, trepo, repos, config, client)
   461  		if err != nil {
   462  			return err
   463  		}
   464  
   465  		manifestBySizeHash[largeSizeIdx] = manifestBySize
   466  	} else {
   467  		// Push blob given size
   468  		var err error
   469  		manifestHash, repos, err = pushMonolithImage(workdir, url, trepo, repos, config, client)
   470  		if err != nil {
   471  			return err
   472  		}
   473  	}
   474  
   475  	manifestItem := manifestStruct{
   476  		manifestHash:       manifestHash,
   477  		manifestBySizeHash: manifestBySizeHash,
   478  	}
   479  
   480  	// download image
   481  	for count := 0; count < requests; count++ {
   482  		repos = pullAndCollect(url, repos, manifestItem, config, client, statsCh)
   483  	}
   484  
   485  	// clean up
   486  	if !skipCleanup {
   487  		err := deleteTestRepo(repos, url, client)
   488  		if err != nil {
   489  			return err
   490  		}
   491  	}
   492  
   493  	return nil
   494  }
   495  
   496  func MixedPullAndPush(
   497  	workdir, url, trepo string,
   498  	requests int,
   499  	config testConfig,
   500  	statsCh chan statsRecord,
   501  	client *resty.Client,
   502  	skipCleanup bool,
   503  ) error {
   504  	var repos []string
   505  
   506  	statusRequests = sync.Map{}
   507  
   508  	// Push blob given size
   509  	manifestHash, repos, err := pushMonolithImage(workdir, url, trepo, repos, config, client)
   510  	if err != nil {
   511  		return err
   512  	}
   513  
   514  	manifestItem := manifestStruct{
   515  		manifestHash: manifestHash,
   516  	}
   517  
   518  	for count := 0; count < requests; count++ {
   519  		idx := flipFunc(config.probabilityRange)
   520  
   521  		readTestIdx := 0
   522  		writeTestIdx := 1
   523  
   524  		if idx == readTestIdx {
   525  			repos = pullAndCollect(url, repos, manifestItem, config, client, statsCh)
   526  			current := loadOrStore(&statusRequests, "Pull", 0)
   527  			statusRequests.Store("Pull", current+1)
   528  		} else if idx == writeTestIdx {
   529  			repos = pushMonolithAndCollect(workdir, url, trepo, count, repos, config, client, statsCh)
   530  			current := loadOrStore(&statusRequests, "Push", 0)
   531  			statusRequests.Store("Pull", current+1)
   532  		}
   533  	}
   534  
   535  	// clean up
   536  	if !skipCleanup {
   537  		err = deleteTestRepo(repos, url, client)
   538  		if err != nil {
   539  			return err
   540  		}
   541  	}
   542  
   543  	return nil
   544  }
   545  
   546  // test driver.
   547  
   548  type testConfig struct {
   549  	name  string
   550  	tfunc testFunc
   551  	// test-specific params
   552  	size                 int
   553  	probabilityRange     []float64
   554  	mixedSize, mixedType bool
   555  }
   556  
   557  var testSuite = []testConfig{ //nolint:gochecknoglobals // used only in this test
   558  	{
   559  		name:             "Get Catalog",
   560  		tfunc:            GetCatalog,
   561  		probabilityRange: normalizeProbabilityRange([]float64{0.7, 0.2, 0.1}),
   562  	},
   563  	{
   564  		name:  "Push Monolith 1MB",
   565  		tfunc: PushMonolithStreamed,
   566  		size:  smallBlob,
   567  	},
   568  	{
   569  		name:  "Push Monolith 10MB",
   570  		tfunc: PushMonolithStreamed,
   571  		size:  mediumBlob,
   572  	},
   573  	{
   574  		name:  "Push Monolith 100MB",
   575  		tfunc: PushMonolithStreamed,
   576  		size:  largeBlob,
   577  	},
   578  	{
   579  		name:  "Push Chunk Streamed 1MB",
   580  		tfunc: PushChunkStreamed,
   581  		size:  smallBlob,
   582  	},
   583  	{
   584  		name:  "Push Chunk Streamed 10MB",
   585  		tfunc: PushChunkStreamed,
   586  		size:  mediumBlob,
   587  	},
   588  	{
   589  		name:  "Push Chunk Streamed 100MB",
   590  		tfunc: PushChunkStreamed,
   591  		size:  largeBlob,
   592  	},
   593  	{
   594  		name:  "Pull 1MB",
   595  		tfunc: Pull,
   596  		size:  smallBlob,
   597  	},
   598  	{
   599  		name:  "Pull 10MB",
   600  		tfunc: Pull,
   601  		size:  mediumBlob,
   602  	},
   603  	{
   604  		name:  "Pull 100MB",
   605  		tfunc: Pull,
   606  		size:  largeBlob,
   607  	},
   608  	{
   609  		name:             "Pull Mixed 20% 1MB, 70% 10MB, 10% 100MB",
   610  		tfunc:            Pull,
   611  		probabilityRange: normalizeProbabilityRange([]float64{0.2, 0.7, 0.1}),
   612  		mixedSize:        true,
   613  	},
   614  	{
   615  		name:             "Push Monolith Mixed 20% 1MB, 70% 10MB, 10% 100MB",
   616  		tfunc:            PushMonolithStreamed,
   617  		probabilityRange: normalizeProbabilityRange([]float64{0.2, 0.7, 0.1}),
   618  		mixedSize:        true,
   619  	},
   620  	{
   621  		name:             "Push Chunk Mixed 33% 1MB, 33% 10MB, 33% 100MB",
   622  		tfunc:            PushChunkStreamed,
   623  		probabilityRange: normalizeProbabilityRange([]float64{0.33, 0.33, 0.33}),
   624  		mixedSize:        true,
   625  	},
   626  	{
   627  		name:             "Pull 75% and Push 25% Mixed 1MB",
   628  		tfunc:            MixedPullAndPush,
   629  		size:             smallBlob,
   630  		mixedType:        true,
   631  		probabilityRange: normalizeProbabilityRange([]float64{0.75, 0.25}),
   632  	},
   633  	{
   634  		name:             "Pull 75% and Push 25% Mixed 10MB",
   635  		tfunc:            MixedPullAndPush,
   636  		size:             mediumBlob,
   637  		mixedType:        true,
   638  		probabilityRange: normalizeProbabilityRange([]float64{0.75, 0.25}),
   639  	},
   640  	{
   641  		name:             "Pull 75% and Push 25% Mixed 100MB",
   642  		tfunc:            MixedPullAndPush,
   643  		size:             largeBlob,
   644  		mixedType:        true,
   645  		probabilityRange: normalizeProbabilityRange([]float64{0.75, 0.25}),
   646  	},
   647  }
   648  
   649  func Perf(
   650  	workdir, url, auth, repo string,
   651  	concurrency int, requests int,
   652  	outFmt string, srcIPs string, srcCIDR string, skipCleanup bool,
   653  ) {
   654  	json := jsoniter.ConfigCompatibleWithStandardLibrary
   655  	// logging
   656  	log.SetFlags(0)
   657  	log.SetOutput(tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent))
   658  
   659  	// common header
   660  	log.Printf("Registry URL:\t%s", url)
   661  	log.Printf("\n")
   662  	log.Printf("Concurrency Level:\t%v", concurrency)
   663  	log.Printf("Total requests:\t%v", requests)
   664  
   665  	if workdir == "" {
   666  		cwd, err := os.Getwd()
   667  		if err != nil {
   668  			log.Fatal("unable to get current working dir")
   669  		}
   670  
   671  		log.Printf("Working dir:\t%v", cwd)
   672  	} else {
   673  		log.Printf("Working dir:\t%v", workdir)
   674  	}
   675  
   676  	log.Printf("\n")
   677  
   678  	// initialize test data
   679  	log.Printf("Preparing test data ...\n")
   680  
   681  	setup(workdir)
   682  	defer teardown(workdir)
   683  
   684  	log.Printf("Starting tests ...\n")
   685  
   686  	var err error
   687  	zbError := false
   688  
   689  	// get host ips from command line to make requests from
   690  	var ips []string
   691  	if len(srcIPs) > 0 {
   692  		ips = strings.Split(srcIPs, ",")
   693  	} else if len(srcCIDR) > 0 {
   694  		ips, err = getIPsFromCIDR(srcCIDR, maxSourceIPs)
   695  		if err != nil {
   696  			log.Fatal(err) //nolint: gocritic
   697  		}
   698  	}
   699  
   700  	for _, tconfig := range testSuite {
   701  		statsCh := make(chan statsRecord, requests)
   702  
   703  		var wg sync.WaitGroup
   704  
   705  		summary := newStatsSummary(tconfig.name)
   706  
   707  		start := time.Now()
   708  
   709  		for c := 0; c < concurrency; c++ {
   710  			// parallelize with clients
   711  			wg.Add(1)
   712  
   713  			go func() {
   714  				defer wg.Done()
   715  
   716  				httpClient, err := getRandomClientIPs(auth, url, ips)
   717  				if err != nil {
   718  					log.Fatal(err)
   719  				}
   720  
   721  				err = tconfig.tfunc(workdir, url, repo, requests/concurrency, tconfig, statsCh, httpClient, skipCleanup)
   722  				if err != nil {
   723  					log.Fatal(err)
   724  				}
   725  			}()
   726  		}
   727  		wg.Wait()
   728  
   729  		summary.total = time.Since(start)
   730  		summary.rps = float32(requests) / float32(summary.total.Seconds())
   731  
   732  		if tconfig.mixedSize || tconfig.size == 0 {
   733  			summary.mixedSize = true
   734  		}
   735  
   736  		if tconfig.mixedType {
   737  			summary.mixedType = true
   738  		}
   739  
   740  		for count := 0; count < requests; count++ {
   741  			record := <-statsCh
   742  			updateStats(&summary, record)
   743  		}
   744  
   745  		sort.Sort(Durations(summary.latencies))
   746  
   747  		printStats(requests, &summary, outFmt)
   748  
   749  		if summary.errors != 0 && !zbError {
   750  			zbError = true
   751  		}
   752  	}
   753  
   754  	if outFmt == cicdFmt {
   755  		jsonOut, err := json.Marshal(cicdSummary)
   756  		if err != nil {
   757  			log.Fatal(err) // file closed on exit
   758  		}
   759  
   760  		if err := os.WriteFile(fmt.Sprintf("%s.json", outFmt), jsonOut, defaultFilePerms); err != nil {
   761  			log.Fatal(err)
   762  		}
   763  	}
   764  
   765  	if zbError {
   766  		os.Exit(1)
   767  	}
   768  }
   769  
   770  // getRandomClientIPs returns a resty client with a random bind address from ips slice.
   771  func getRandomClientIPs(auth string, url string, ips []string) (*resty.Client, error) {
   772  	client := resty.New()
   773  
   774  	if auth != "" {
   775  		creds := strings.Split(auth, ":")
   776  		client.SetBasicAuth(creds[0], creds[1])
   777  	}
   778  
   779  	// get random ip client
   780  	if len(ips) != 0 {
   781  		// get random number
   782  		nBig, err := crand.Int(crand.Reader, big.NewInt(int64(len(ips))))
   783  		if err != nil {
   784  			return nil, err
   785  		}
   786  
   787  		// get random ip
   788  		ip := ips[nBig.Int64()]
   789  
   790  		// set ip in transport
   791  		localAddr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:0", ip))
   792  		if err != nil {
   793  			return nil, err
   794  		}
   795  
   796  		transport := &http.Transport{
   797  			Proxy: http.ProxyFromEnvironment,
   798  			DialContext: (&net.Dialer{
   799  				Timeout:   httpTimeout,
   800  				KeepAlive: httpKeepAlive,
   801  				LocalAddr: localAddr,
   802  			}).DialContext,
   803  			TLSHandshakeTimeout: TLSHandshakeTimeout,
   804  		}
   805  
   806  		client.SetTransport(transport)
   807  	}
   808  
   809  	parsedURL, err := urlparser.Parse(url)
   810  	if err != nil {
   811  		log.Fatal(err)
   812  	}
   813  
   814  	//nolint: gosec
   815  	if parsedURL.Scheme == secureProtocol {
   816  		client.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
   817  	}
   818  
   819  	return client, nil
   820  }
   821  
   822  // getIPsFromCIDR returns a list of ips given a cidr.
   823  func getIPsFromCIDR(cidr string, maxIPs int) ([]string, error) {
   824  	//nolint:varnamelen
   825  	ip, ipnet, err := net.ParseCIDR(cidr)
   826  	if err != nil {
   827  		return nil, err
   828  	}
   829  
   830  	var ips []string
   831  	for ip := ip.Mask(ipnet.Mask); ipnet.Contains(ip) && len(ips) < maxIPs; inc(ip) {
   832  		ips = append(ips, ip.String())
   833  	}
   834  	// remove network address and broadcast address
   835  	return ips[1 : len(ips)-1], nil
   836  }
   837  
   838  // https://go.dev/play/p/sdzcMvZYWnc
   839  func inc(ip net.IP) {
   840  	for j := len(ip) - 1; j >= 0; j-- {
   841  		ip[j]++
   842  		if ip[j] > 0 {
   843  			break
   844  		}
   845  	}
   846  }