github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/apiserver/tools_test.go (about)

     1  // Copyright 2012, 2013 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  package apiserver_test
     5  
     6  import (
     7  	"bytes"
     8  	"crypto/sha256"
     9  	"encoding/json"
    10  	"fmt"
    11  	"io"
    12  	"net/http"
    13  	"net/url"
    14  	"os"
    15  	"path/filepath"
    16  	"strings"
    17  	"sync"
    18  	"time"
    19  
    20  	"github.com/juju/errors"
    21  	jc "github.com/juju/testing/checkers"
    22  	"github.com/juju/utils/v3"
    23  	"github.com/juju/version/v2"
    24  	gc "gopkg.in/check.v1"
    25  
    26  	apitesting "github.com/juju/juju/apiserver/testing"
    27  	"github.com/juju/juju/environs"
    28  	"github.com/juju/juju/environs/simplestreams"
    29  	"github.com/juju/juju/environs/storage"
    30  	envtesting "github.com/juju/juju/environs/testing"
    31  	envtools "github.com/juju/juju/environs/tools"
    32  	toolstesting "github.com/juju/juju/environs/tools/testing"
    33  	"github.com/juju/juju/rpc/params"
    34  	"github.com/juju/juju/state"
    35  	"github.com/juju/juju/state/binarystorage"
    36  	"github.com/juju/juju/testing"
    37  	"github.com/juju/juju/testing/factory"
    38  	coretools "github.com/juju/juju/tools"
    39  )
    40  
    41  type baseToolsSuite struct {
    42  	apiserverBaseSuite
    43  }
    44  
    45  func (s *baseToolsSuite) toolsURL(query string) *url.URL {
    46  	return s.modelToolsURL(s.Model.UUID(), query)
    47  }
    48  
    49  func (s *baseToolsSuite) modelToolsURL(model, query string) *url.URL {
    50  	u := s.URL(fmt.Sprintf("/model/%s/tools", model), nil)
    51  	u.RawQuery = query
    52  	return u
    53  }
    54  
    55  func (s *baseToolsSuite) toolsURI(query string) string {
    56  	if query != "" && query[0] == '?' {
    57  		query = query[1:]
    58  	}
    59  	return s.toolsURL(query).String()
    60  }
    61  
    62  func (s *baseToolsSuite) uploadRequest(c *gc.C, url, contentType string, content io.Reader) *http.Response {
    63  	return s.sendHTTPRequest(c, apitesting.HTTPRequestParams{
    64  		Method:      "POST",
    65  		URL:         url,
    66  		ContentType: contentType,
    67  		Body:        content,
    68  	})
    69  }
    70  
    71  func (s *baseToolsSuite) downloadRequest(c *gc.C, version version.Binary, uuid string) *http.Response {
    72  	url := s.toolsURL("")
    73  	if uuid == "" {
    74  		url.Path = fmt.Sprintf("/tools/%s", version)
    75  	} else {
    76  		url.Path = fmt.Sprintf("/model/%s/tools/%s", uuid, version)
    77  	}
    78  	return apitesting.SendHTTPRequest(c, apitesting.HTTPRequestParams{Method: "GET", URL: url.String()})
    79  }
    80  
    81  func (s *baseToolsSuite) assertUploadResponse(c *gc.C, resp *http.Response, agentTools *coretools.Tools) {
    82  	toolsResponse := s.assertResponse(c, resp, http.StatusOK)
    83  	c.Check(toolsResponse.Error, gc.IsNil)
    84  	c.Check(toolsResponse.ToolsList, jc.DeepEquals, coretools.List{agentTools})
    85  }
    86  
    87  func (s *baseToolsSuite) assertJSONErrorResponse(c *gc.C, resp *http.Response, expCode int, expError string) {
    88  	toolsResponse := s.assertResponse(c, resp, expCode)
    89  	c.Check(toolsResponse.ToolsList, gc.IsNil)
    90  	c.Check(toolsResponse.Error, gc.NotNil)
    91  	c.Check(toolsResponse.Error.Message, gc.Matches, expError)
    92  }
    93  
    94  func (s *baseToolsSuite) assertPlainErrorResponse(c *gc.C, resp *http.Response, expCode int, expError string) {
    95  	body := apitesting.AssertResponse(c, resp, expCode, "text/plain; charset=utf-8")
    96  	c.Assert(string(body), gc.Matches, expError+"\n")
    97  }
    98  
    99  func (s *baseToolsSuite) assertResponse(c *gc.C, resp *http.Response, expStatus int) params.ToolsResult {
   100  	body := apitesting.AssertResponse(c, resp, expStatus, params.ContentTypeJSON)
   101  	var toolsResponse params.ToolsResult
   102  	err := json.Unmarshal(body, &toolsResponse)
   103  	c.Assert(err, jc.ErrorIsNil, gc.Commentf("Body: %s", body))
   104  	return toolsResponse
   105  }
   106  
   107  func (s *baseToolsSuite) storeFakeTools(c *gc.C, st *state.State, content string, metadata binarystorage.Metadata) *coretools.Tools {
   108  	storage, err := st.ToolsStorage()
   109  	c.Assert(err, jc.ErrorIsNil)
   110  	defer storage.Close()
   111  	err = storage.Add(strings.NewReader(content), metadata)
   112  	c.Assert(err, jc.ErrorIsNil)
   113  	return &coretools.Tools{
   114  		Version: version.MustParseBinary(metadata.Version),
   115  		Size:    metadata.Size,
   116  		SHA256:  metadata.SHA256,
   117  	}
   118  }
   119  
   120  func (s *baseToolsSuite) getToolsFromStorage(c *gc.C, st *state.State, vers string) (binarystorage.Metadata, []byte) {
   121  	storage, err := st.ToolsStorage()
   122  	c.Assert(err, jc.ErrorIsNil)
   123  	defer storage.Close()
   124  	metadata, r, err := storage.Open(vers)
   125  	c.Assert(err, jc.ErrorIsNil)
   126  	data, err := io.ReadAll(r)
   127  	r.Close()
   128  	c.Assert(err, jc.ErrorIsNil)
   129  	return metadata, data
   130  }
   131  
   132  func (s *baseToolsSuite) getToolsMetadataFromStorage(c *gc.C, st *state.State) []binarystorage.Metadata {
   133  	storage, err := st.ToolsStorage()
   134  	c.Assert(err, jc.ErrorIsNil)
   135  	defer storage.Close()
   136  	metadata, err := storage.AllMetadata()
   137  	c.Assert(err, jc.ErrorIsNil)
   138  	return metadata
   139  }
   140  
   141  func (s *baseToolsSuite) testDownload(c *gc.C, tools *coretools.Tools, uuid string) []byte {
   142  	resp := s.downloadRequest(c, tools.Version, uuid)
   143  	defer resp.Body.Close()
   144  	data, err := io.ReadAll(resp.Body)
   145  	c.Assert(err, jc.ErrorIsNil)
   146  	c.Assert(data, gc.HasLen, int(tools.Size))
   147  
   148  	hash := sha256.New()
   149  	hash.Write(data)
   150  	c.Assert(fmt.Sprintf("%x", hash.Sum(nil)), gc.Equals, tools.SHA256)
   151  	return data
   152  }
   153  
   154  type toolsSuite struct {
   155  	baseToolsSuite
   156  }
   157  
   158  var _ = gc.Suite(&toolsSuite{})
   159  
   160  func (s *toolsSuite) TestToolsUploadedSecurely(c *gc.C) {
   161  	url := s.toolsURL("")
   162  	url.Scheme = "http"
   163  	apitesting.SendHTTPRequest(c, apitesting.HTTPRequestParams{
   164  		Method:       "PUT",
   165  		URL:          url.String(),
   166  		ExpectStatus: http.StatusBadRequest,
   167  	})
   168  }
   169  
   170  func (s *toolsSuite) TestRequiresAuth(c *gc.C) {
   171  	resp := apitesting.SendHTTPRequest(c, apitesting.HTTPRequestParams{Method: "GET", URL: s.toolsURI("")})
   172  	s.assertPlainErrorResponse(c, resp, http.StatusUnauthorized, "authentication failed: no credentials provided")
   173  }
   174  
   175  func (s *toolsSuite) TestRequiresPOST(c *gc.C) {
   176  	resp := s.sendHTTPRequest(c, apitesting.HTTPRequestParams{Method: "PUT", URL: s.toolsURI("")})
   177  	s.assertJSONErrorResponse(c, resp, http.StatusMethodNotAllowed, `unsupported method: "PUT"`)
   178  }
   179  
   180  func (s *toolsSuite) TestAuthRequiresUser(c *gc.C) {
   181  	// Add a machine and try to login.
   182  	machine, err := s.State.AddMachine(state.UbuntuBase("12.10"), state.JobHostUnits)
   183  	c.Assert(err, jc.ErrorIsNil)
   184  	err = machine.SetProvisioned("foo", "", "fake_nonce", nil)
   185  	c.Assert(err, jc.ErrorIsNil)
   186  	password, err := utils.RandomPassword()
   187  	c.Assert(err, jc.ErrorIsNil)
   188  	err = machine.SetPassword(password)
   189  	c.Assert(err, jc.ErrorIsNil)
   190  
   191  	resp := apitesting.SendHTTPRequest(c, apitesting.HTTPRequestParams{
   192  		Tag:      machine.Tag().String(),
   193  		Password: password,
   194  		Method:   "POST",
   195  		URL:      s.toolsURI(""),
   196  		Nonce:    "fake_nonce",
   197  	})
   198  	s.assertPlainErrorResponse(
   199  		c, resp, http.StatusForbidden,
   200  		"authorization failed: tag kind machine not valid",
   201  	)
   202  
   203  	// Now try a user login.
   204  	resp = s.sendHTTPRequest(c, apitesting.HTTPRequestParams{Method: "POST", URL: s.toolsURI("")})
   205  	s.assertJSONErrorResponse(c, resp, http.StatusBadRequest, "expected binaryVersion argument")
   206  }
   207  
   208  func (s *toolsSuite) TestUploadRequiresVersion(c *gc.C) {
   209  	resp := s.sendHTTPRequest(c, apitesting.HTTPRequestParams{Method: "POST", URL: s.toolsURI("")})
   210  	s.assertJSONErrorResponse(c, resp, http.StatusBadRequest, "expected binaryVersion argument")
   211  }
   212  
   213  func (s *toolsSuite) TestUploadFailsWithNoTools(c *gc.C) {
   214  	var empty bytes.Buffer
   215  	resp := s.uploadRequest(c, s.toolsURI("?binaryVersion=1.18.0-ubuntu-amd64"), "application/x-tar-gz", &empty)
   216  	s.assertJSONErrorResponse(c, resp, http.StatusBadRequest, "no agent binaries uploaded")
   217  }
   218  
   219  func (s *toolsSuite) TestUploadFailsWithInvalidContentType(c *gc.C) {
   220  	var empty bytes.Buffer
   221  	// Now try with the default Content-Type.
   222  	resp := s.uploadRequest(c, s.toolsURI("?binaryVersion=1.18.0-ubuntu-amd64"), "application/octet-stream", &empty)
   223  	s.assertJSONErrorResponse(
   224  		c, resp, http.StatusBadRequest, "expected Content-Type: application/x-tar-gz, got: application/octet-stream")
   225  }
   226  
   227  func (s *toolsSuite) setupToolsForUpload(c *gc.C) (coretools.List, version.Binary, []byte) {
   228  	localStorage := c.MkDir()
   229  	vers := version.MustParseBinary("1.9.0-ubuntu-amd64")
   230  	versionStrings := []string{vers.String()}
   231  	expectedTools := toolstesting.MakeToolsWithCheckSum(c, localStorage, "released", versionStrings)
   232  	toolsFile := envtools.StorageName(vers, "released")
   233  	toolsContent, err := os.ReadFile(filepath.Join(localStorage, toolsFile))
   234  	c.Assert(err, jc.ErrorIsNil)
   235  	return expectedTools, vers, toolsContent
   236  }
   237  
   238  func (s *toolsSuite) TestUpload(c *gc.C) {
   239  	// Make some fake tools.
   240  	expectedTools, v, toolsContent := s.setupToolsForUpload(c)
   241  	vers := v.String()
   242  
   243  	// Now try uploading them.
   244  	resp := s.uploadRequest(
   245  		c, s.toolsURI("?binaryVersion="+vers),
   246  		"application/x-tar-gz",
   247  		bytes.NewReader(toolsContent),
   248  	)
   249  
   250  	// Check the response.
   251  	expectedTools[0].URL = s.toolsURL("").String() + "/" + vers
   252  	s.assertUploadResponse(c, resp, expectedTools[0])
   253  
   254  	// Check the contents.
   255  	metadata, uploadedData := s.getToolsFromStorage(c, s.State, vers)
   256  	c.Assert(uploadedData, gc.DeepEquals, toolsContent)
   257  	allMetadata := s.getToolsMetadataFromStorage(c, s.State)
   258  	c.Assert(allMetadata, jc.DeepEquals, []binarystorage.Metadata{metadata})
   259  }
   260  
   261  func (s *toolsSuite) TestMigrateTools(c *gc.C) {
   262  	// Make some fake tools.
   263  	expectedTools, v, toolsContent := s.setupToolsForUpload(c)
   264  	vers := v.String()
   265  
   266  	newSt := s.Factory.MakeModel(c, nil)
   267  	defer newSt.Close()
   268  	importedModel, err := newSt.Model()
   269  	c.Assert(err, jc.ErrorIsNil)
   270  	err = importedModel.SetMigrationMode(state.MigrationModeImporting)
   271  	c.Assert(err, jc.ErrorIsNil)
   272  
   273  	// Now try uploading them.
   274  	uri := s.URL("/migrate/tools", url.Values{"binaryVersion": {vers}})
   275  	resp := s.sendHTTPRequest(c, apitesting.HTTPRequestParams{
   276  		Method:      "POST",
   277  		URL:         uri.String(),
   278  		ContentType: "application/x-tar-gz",
   279  		Body:        bytes.NewReader(toolsContent),
   280  		ExtraHeaders: map[string]string{
   281  			params.MigrationModelHTTPHeader: importedModel.UUID(),
   282  		},
   283  	})
   284  
   285  	// Check the response.
   286  	expectedTools[0].URL = s.modelToolsURL(s.State.ControllerModelUUID(), "").String() + "/" + vers
   287  	s.assertUploadResponse(c, resp, expectedTools[0])
   288  
   289  	// Check the contents.
   290  	metadata, uploadedData := s.getToolsFromStorage(c, newSt, vers)
   291  	c.Assert(uploadedData, gc.DeepEquals, toolsContent)
   292  	allMetadata := s.getToolsMetadataFromStorage(c, newSt)
   293  	c.Assert(allMetadata, jc.DeepEquals, []binarystorage.Metadata{metadata})
   294  }
   295  
   296  func (s *toolsSuite) TestMigrateToolsNotMigrating(c *gc.C) {
   297  	// Make some fake tools.
   298  	_, v, toolsContent := s.setupToolsForUpload(c)
   299  	vers := v.String()
   300  
   301  	newSt := s.Factory.MakeModel(c, nil)
   302  	defer newSt.Close()
   303  
   304  	uri := s.URL("/migrate/tools", url.Values{"binaryVersion": {vers}})
   305  	resp := s.sendHTTPRequest(c, apitesting.HTTPRequestParams{
   306  		Method:      "POST",
   307  		URL:         uri.String(),
   308  		ContentType: "application/x-tar-gz",
   309  		Body:        bytes.NewReader(toolsContent),
   310  		ExtraHeaders: map[string]string{
   311  			params.MigrationModelHTTPHeader: newSt.ModelUUID(),
   312  		},
   313  	})
   314  
   315  	// Now try uploading them.
   316  	s.assertJSONErrorResponse(
   317  		c, resp, http.StatusBadRequest,
   318  		`model migration mode is "" instead of "importing"`,
   319  	)
   320  }
   321  
   322  func (s *toolsSuite) TestMigrateToolsUnauth(c *gc.C) {
   323  	// Try uploading as a non controller admin.
   324  	url := s.URL("/migrate/tools", nil).String()
   325  	user := s.Factory.MakeUser(c, &factory.UserParams{Password: "hunter2"})
   326  	resp := apitesting.SendHTTPRequest(c, apitesting.HTTPRequestParams{
   327  		Method:   "POST",
   328  		URL:      url,
   329  		Tag:      user.Tag().String(),
   330  		Password: "hunter2",
   331  	})
   332  	s.assertPlainErrorResponse(
   333  		c, resp, http.StatusForbidden,
   334  		"authorization failed: user .* is not a controller admin",
   335  	)
   336  }
   337  
   338  func (s *toolsSuite) TestBlockUpload(c *gc.C) {
   339  	// Make some fake tools.
   340  	_, v, toolsContent := s.setupToolsForUpload(c)
   341  	vers := v.String()
   342  
   343  	// Block all changes.
   344  	err := s.State.SwitchBlockOn(state.ChangeBlock, "TestUpload")
   345  	c.Assert(err, jc.ErrorIsNil)
   346  
   347  	// Now try uploading them.
   348  	resp := s.uploadRequest(
   349  		c, s.toolsURI("?binaryVersion="+vers),
   350  		"application/x-tar-gz",
   351  		bytes.NewReader(toolsContent),
   352  	)
   353  	toolsResponse := s.assertResponse(c, resp, http.StatusBadRequest)
   354  	c.Assert(toolsResponse.Error, jc.Satisfies, params.IsCodeOperationBlocked)
   355  	c.Assert(errors.Cause(toolsResponse.Error), gc.DeepEquals, &params.Error{
   356  		Message: "TestUpload",
   357  		Code:    "operation is blocked",
   358  	})
   359  
   360  	// Check the contents.
   361  	storage, err := s.State.ToolsStorage()
   362  	c.Assert(err, jc.ErrorIsNil)
   363  	defer storage.Close()
   364  	_, _, err = storage.Open(vers)
   365  	c.Assert(errors.IsNotFound(err), jc.IsTrue)
   366  }
   367  
   368  func (s *toolsSuite) TestUploadAllowsTopLevelPath(c *gc.C) {
   369  	// Backwards compatibility check, that we can upload tools to
   370  	// https://host:port/tools
   371  	expectedTools, vers, toolsContent := s.setupToolsForUpload(c)
   372  	url := s.toolsURL("binaryVersion=" + vers.String())
   373  	url.Path = "/tools"
   374  	resp := s.uploadRequest(c, url.String(), "application/x-tar-gz", bytes.NewReader(toolsContent))
   375  	expectedTools[0].URL = s.modelToolsURL(s.State.ControllerModelUUID(), "").String() + "/" + vers.String()
   376  	s.assertUploadResponse(c, resp, expectedTools[0])
   377  }
   378  
   379  func (s *toolsSuite) TestUploadAllowsModelUUIDPath(c *gc.C) {
   380  	// Check that we can upload tools to https://host:port/ModelUUID/tools
   381  	expectedTools, vers, toolsContent := s.setupToolsForUpload(c)
   382  	url := s.toolsURL("binaryVersion=" + vers.String())
   383  	resp := s.uploadRequest(c, url.String(), "application/x-tar-gz", bytes.NewReader(toolsContent))
   384  	// Check the response.
   385  	expectedTools[0].URL = s.toolsURL("").String() + "/" + vers.String()
   386  	s.assertUploadResponse(c, resp, expectedTools[0])
   387  }
   388  
   389  func (s *toolsSuite) TestUploadAllowsOtherModelUUIDPath(c *gc.C) {
   390  	newSt := s.Factory.MakeModel(c, nil)
   391  	defer newSt.Close()
   392  
   393  	// Check that we can upload tools to https://host:port/ModelUUID/tools
   394  	expectedTools, vers, toolsContent := s.setupToolsForUpload(c)
   395  	url := s.modelToolsURL(newSt.ModelUUID(), "binaryVersion="+vers.String())
   396  	resp := s.uploadRequest(c, url.String(), "application/x-tar-gz", bytes.NewReader(toolsContent))
   397  
   398  	// Check the response.
   399  	expectedTools[0].URL = s.modelToolsURL(newSt.ModelUUID(), "").String() + "/" + vers.String()
   400  	s.assertUploadResponse(c, resp, expectedTools[0])
   401  }
   402  
   403  func (s *toolsSuite) TestDownloadModelUUIDPath(c *gc.C) {
   404  	tools := s.storeFakeTools(c, s.State, "abc", binarystorage.Metadata{
   405  		Version: testing.CurrentVersion().String(),
   406  		Size:    3,
   407  		SHA256:  "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
   408  	})
   409  	s.testDownload(c, tools, s.State.ModelUUID())
   410  }
   411  
   412  func (s *toolsSuite) TestDownloadOtherModelUUIDPath(c *gc.C) {
   413  	newSt := s.Factory.MakeModel(c, nil)
   414  	defer newSt.Close()
   415  
   416  	tools := s.storeFakeTools(c, newSt, "abc", binarystorage.Metadata{
   417  		Version: testing.CurrentVersion().String(),
   418  		Size:    3,
   419  		SHA256:  "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
   420  	})
   421  	s.testDownload(c, tools, newSt.ModelUUID())
   422  }
   423  
   424  func (s *toolsSuite) TestDownloadTopLevelPath(c *gc.C) {
   425  	tools := s.storeFakeTools(c, s.State, "abc", binarystorage.Metadata{
   426  		Version: testing.CurrentVersion().String(),
   427  		Size:    3,
   428  		SHA256:  "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
   429  	})
   430  	s.testDownload(c, tools, "")
   431  }
   432  
   433  func (s *toolsSuite) TestDownloadMissingConcurrent(c *gc.C) {
   434  	closer, testStorage, _ := envtesting.CreateLocalTestStorage(c)
   435  	defer closer.Close()
   436  
   437  	var mut sync.Mutex
   438  	resolutions := 0
   439  	envtools.RegisterToolsDataSourceFunc("local storage", func(environs.Environ) (simplestreams.DataSource, error) {
   440  		// Add some delay to make sure all goroutines are waiting.
   441  		time.Sleep(10 * time.Millisecond)
   442  		mut.Lock()
   443  		defer mut.Unlock()
   444  		resolutions++
   445  		return storage.NewStorageSimpleStreamsDataSource("test datasource", testStorage, "tools", simplestreams.CUSTOM_CLOUD_DATA, false), nil
   446  	})
   447  	defer envtools.UnregisterToolsDataSourceFunc("local storage")
   448  
   449  	toolsBinaries := []version.Binary{
   450  		version.MustParseBinary("2.9.98-ubuntu-amd64"),
   451  		version.MustParseBinary("2.9.99-ubuntu-amd64"),
   452  	}
   453  	stream := "released"
   454  	tools, err := envtesting.UploadFakeToolsVersions(testStorage, stream, stream, toolsBinaries...)
   455  	c.Assert(err, jc.ErrorIsNil)
   456  
   457  	var wg sync.WaitGroup
   458  	const n = 8
   459  	wg.Add(n)
   460  	for i := 0; i < n; i++ {
   461  		tool := tools[i%len(toolsBinaries)]
   462  		go func() {
   463  			defer wg.Done()
   464  			s.testDownload(c, tool, s.State.ModelUUID())
   465  		}()
   466  	}
   467  	wg.Wait()
   468  
   469  	c.Assert(resolutions, gc.Equals, len(toolsBinaries))
   470  }
   471  
   472  type caasToolsSuite struct {
   473  	baseToolsSuite
   474  }
   475  
   476  var _ = gc.Suite(&caasToolsSuite{})
   477  
   478  func (s *caasToolsSuite) SetUpTest(c *gc.C) {
   479  	s.ControllerModelType = state.ModelTypeCAAS
   480  	s.baseToolsSuite.SetUpTest(c)
   481  }
   482  
   483  func (s *caasToolsSuite) TestToolDownloadNotSharedCAASController(c *gc.C) {
   484  	closer, testStorage, _ := envtesting.CreateLocalTestStorage(c)
   485  	defer closer.Close()
   486  
   487  	const n = 8
   488  	states := []*state.State{}
   489  	for i := 0; i < n; i++ {
   490  		testState := s.Factory.MakeModel(c, nil)
   491  		defer testState.Close()
   492  		states = append(states, testState)
   493  	}
   494  
   495  	var mut sync.Mutex
   496  	resolutions := 0
   497  	envtools.RegisterToolsDataSourceFunc("local storage", func(environs.Environ) (simplestreams.DataSource, error) {
   498  		// Add some delay to make sure all goroutines are waiting.
   499  		time.Sleep(10 * time.Millisecond)
   500  		mut.Lock()
   501  		defer mut.Unlock()
   502  		resolutions++
   503  		return storage.NewStorageSimpleStreamsDataSource("test datasource", testStorage, "tools", simplestreams.CUSTOM_CLOUD_DATA, false), nil
   504  	})
   505  	defer envtools.UnregisterToolsDataSourceFunc("local storage")
   506  
   507  	tool := version.MustParseBinary("2.9.99-ubuntu-amd64")
   508  	stream := "released"
   509  	tools, err := envtesting.UploadFakeToolsVersions(testStorage, stream, stream, tool)
   510  	c.Assert(err, jc.ErrorIsNil)
   511  	c.Assert(tools, gc.HasLen, 1)
   512  
   513  	var wg sync.WaitGroup
   514  	wg.Add(n)
   515  	for i := 0; i < n; i++ {
   516  		i := i
   517  		go func() {
   518  			defer wg.Done()
   519  			s.testDownload(c, tools[0], states[i].ModelUUID())
   520  		}()
   521  	}
   522  	wg.Wait()
   523  
   524  	c.Assert(resolutions, gc.Equals, n)
   525  }