github.com/Pankov404/juju@v0.0.0-20150703034450-be266991dceb/apiserver/service/service_test.go (about)

     1  // Copyright 2014 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  package service_test
     5  
     6  import (
     7  	"fmt"
     8  	"io"
     9  	"sync"
    10  
    11  	"github.com/juju/errors"
    12  	jc "github.com/juju/testing/checkers"
    13  	"github.com/juju/utils"
    14  	gc "gopkg.in/check.v1"
    15  	"gopkg.in/juju/charm.v5"
    16  	"gopkg.in/juju/charmstore.v4/csclient"
    17  	"gopkg.in/macaroon.v1"
    18  	"gopkg.in/mgo.v2"
    19  
    20  	commontesting "github.com/juju/juju/apiserver/common/testing"
    21  	"github.com/juju/juju/apiserver/params"
    22  	"github.com/juju/juju/apiserver/service"
    23  	apiservertesting "github.com/juju/juju/apiserver/testing"
    24  	"github.com/juju/juju/constraints"
    25  	jujutesting "github.com/juju/juju/juju/testing"
    26  	"github.com/juju/juju/state"
    27  	statestorage "github.com/juju/juju/state/storage"
    28  	"github.com/juju/juju/storage"
    29  	"github.com/juju/juju/storage/poolmanager"
    30  	"github.com/juju/juju/storage/provider"
    31  	"github.com/juju/juju/storage/provider/registry"
    32  	"github.com/juju/juju/testcharms"
    33  	"github.com/juju/juju/testing/factory"
    34  )
    35  
    36  type serviceSuite struct {
    37  	jujutesting.JujuConnSuite
    38  	apiservertesting.CharmStoreSuite
    39  	commontesting.BlockHelper
    40  
    41  	serviceApi *service.API
    42  	service    *state.Service
    43  	authorizer apiservertesting.FakeAuthorizer
    44  }
    45  
    46  var _ = gc.Suite(&serviceSuite{})
    47  
    48  var _ service.Service = (*service.API)(nil)
    49  
    50  func (s *serviceSuite) SetUpSuite(c *gc.C) {
    51  	s.CharmStoreSuite.SetUpSuite(c)
    52  	s.JujuConnSuite.SetUpSuite(c)
    53  }
    54  
    55  func (s *serviceSuite) TearDownSuite(c *gc.C) {
    56  	s.CharmStoreSuite.TearDownSuite(c)
    57  	s.JujuConnSuite.TearDownSuite(c)
    58  }
    59  
    60  func (s *serviceSuite) SetUpTest(c *gc.C) {
    61  	s.JujuConnSuite.SetUpTest(c)
    62  	s.BlockHelper = commontesting.NewBlockHelper(s.APIState)
    63  	s.AddCleanup(func(*gc.C) { s.BlockHelper.Close() })
    64  
    65  	s.CharmStoreSuite.Session = s.JujuConnSuite.Session
    66  	s.CharmStoreSuite.SetUpTest(c)
    67  
    68  	s.service = s.Factory.MakeService(c, nil)
    69  
    70  	s.authorizer = apiservertesting.FakeAuthorizer{
    71  		Tag: s.AdminUserTag(c),
    72  	}
    73  	var err error
    74  	s.serviceApi, err = service.NewAPI(s.State, nil, s.authorizer)
    75  	c.Assert(err, jc.ErrorIsNil)
    76  }
    77  
    78  func (s *serviceSuite) TearDownTest(c *gc.C) {
    79  	s.CharmStoreSuite.TearDownTest(c)
    80  	s.JujuConnSuite.TearDownTest(c)
    81  }
    82  
    83  func (s *serviceSuite) TestSetMetricCredentials(c *gc.C) {
    84  	charm := s.Factory.MakeCharm(c, &factory.CharmParams{Name: "wordpress"})
    85  	wordpress := s.Factory.MakeService(c, &factory.ServiceParams{
    86  		Charm: charm,
    87  	})
    88  	tests := []struct {
    89  		about   string
    90  		args    params.ServiceMetricCredentials
    91  		results params.ErrorResults
    92  	}{
    93  		{
    94  			"test one argument and it passes",
    95  			params.ServiceMetricCredentials{[]params.ServiceMetricCredential{{
    96  				s.service.Name(),
    97  				[]byte("creds 1234"),
    98  			}}},
    99  			params.ErrorResults{[]params.ErrorResult{{Error: nil}}},
   100  		},
   101  		{
   102  			"test two arguments and both pass",
   103  			params.ServiceMetricCredentials{[]params.ServiceMetricCredential{
   104  				{
   105  					s.service.Name(),
   106  					[]byte("creds 1234"),
   107  				},
   108  				{
   109  					wordpress.Name(),
   110  					[]byte("creds 4567"),
   111  				},
   112  			}},
   113  			params.ErrorResults{[]params.ErrorResult{
   114  				{Error: nil},
   115  				{Error: nil},
   116  			}},
   117  		},
   118  		{
   119  			"test two arguments and second one fails",
   120  			params.ServiceMetricCredentials{[]params.ServiceMetricCredential{
   121  				{
   122  					s.service.Name(),
   123  					[]byte("creds 1234"),
   124  				},
   125  				{
   126  					"not-a-service",
   127  					[]byte("creds 4567"),
   128  				},
   129  			}},
   130  			params.ErrorResults{[]params.ErrorResult{
   131  				{Error: nil},
   132  				{Error: &params.Error{`service "not-a-service" not found`, "not found"}},
   133  			}},
   134  		},
   135  	}
   136  	for i, t := range tests {
   137  		c.Logf("Running test %d %v", i, t.about)
   138  		results, err := s.serviceApi.SetMetricCredentials(t.args)
   139  		c.Assert(err, jc.ErrorIsNil)
   140  		c.Assert(results.Results, gc.HasLen, len(t.results.Results))
   141  		c.Assert(results, gc.DeepEquals, t.results)
   142  
   143  		for i, a := range t.args.Creds {
   144  			if t.results.Results[i].Error == nil {
   145  				svc, err := s.State.Service(a.ServiceName)
   146  				c.Assert(err, jc.ErrorIsNil)
   147  				creds := svc.MetricCredentials()
   148  				c.Assert(creds, gc.DeepEquals, a.MetricCredentials)
   149  			}
   150  		}
   151  	}
   152  }
   153  
   154  func (s *serviceSuite) TestCompatibleSettingsParsing(c *gc.C) {
   155  	// Test the exported settings parsing in a compatible way.
   156  	s.AddTestingService(c, "dummy", s.AddTestingCharm(c, "dummy"))
   157  	svc, err := s.State.Service("dummy")
   158  	c.Assert(err, jc.ErrorIsNil)
   159  	ch, _, err := svc.Charm()
   160  	c.Assert(err, jc.ErrorIsNil)
   161  	c.Assert(ch.URL().String(), gc.Equals, "local:quantal/dummy-1")
   162  
   163  	// Empty string will be returned as nil.
   164  	options := map[string]string{
   165  		"title":    "foobar",
   166  		"username": "",
   167  	}
   168  	settings, err := service.ParseSettingsCompatible(ch, options)
   169  	c.Assert(err, jc.ErrorIsNil)
   170  	c.Assert(settings, gc.DeepEquals, charm.Settings{
   171  		"title":    "foobar",
   172  		"username": nil,
   173  	})
   174  
   175  	// Illegal settings lead to an error.
   176  	options = map[string]string{
   177  		"yummy": "didgeridoo",
   178  	}
   179  	_, err = service.ParseSettingsCompatible(ch, options)
   180  	c.Assert(err, gc.ErrorMatches, `unknown option "yummy"`)
   181  }
   182  
   183  func setupStoragePool(c *gc.C, st *state.State) {
   184  	pm := poolmanager.New(state.NewStateSettings(st))
   185  	_, err := pm.Create("loop-pool", provider.LoopProviderType, map[string]interface{}{})
   186  	c.Assert(err, jc.ErrorIsNil)
   187  	err = st.UpdateEnvironConfig(map[string]interface{}{
   188  		"storage-default-block-source": "loop-pool",
   189  	}, nil, nil)
   190  	c.Assert(err, jc.ErrorIsNil)
   191  }
   192  
   193  func (s *serviceSuite) TestClientServiceDeployWithStorage(c *gc.C) {
   194  	setupStoragePool(c, s.State)
   195  	curl, ch := s.UploadCharm(c, "utopic/storage-block-10", "storage-block")
   196  	storageConstraints := map[string]storage.Constraints{
   197  		"data": {
   198  			Count: 1,
   199  			Size:  1024,
   200  			Pool:  "loop-pool",
   201  		},
   202  	}
   203  
   204  	var cons constraints.Value
   205  	args := params.ServiceDeploy{
   206  		ServiceName: "service",
   207  		CharmUrl:    curl.String(),
   208  		NumUnits:    1,
   209  		Constraints: cons,
   210  		Storage:     storageConstraints,
   211  	}
   212  	results, err := s.serviceApi.ServicesDeploy(params.ServicesDeploy{
   213  		Services: []params.ServiceDeploy{args}},
   214  	)
   215  	c.Assert(err, jc.ErrorIsNil)
   216  	c.Assert(results, gc.DeepEquals, params.ErrorResults{
   217  		Results: []params.ErrorResult{{Error: nil}},
   218  	})
   219  	svc := apiservertesting.AssertPrincipalServiceDeployed(c, s.State, "service", curl, false, ch, cons)
   220  	storageConstraintsOut, err := svc.StorageConstraints()
   221  	c.Assert(err, jc.ErrorIsNil)
   222  	c.Assert(storageConstraintsOut, gc.DeepEquals, map[string]state.StorageConstraints{
   223  		"data": {
   224  			Count: 1,
   225  			Size:  1024,
   226  			Pool:  "loop-pool",
   227  		},
   228  		"allecto": {
   229  			Count: 0,
   230  			Size:  1024,
   231  			Pool:  "loop",
   232  		},
   233  	})
   234  }
   235  
   236  func (s *serviceSuite) TestClientServiceDeployWithInvalidStoragePool(c *gc.C) {
   237  	setupStoragePool(c, s.State)
   238  	curl, _ := s.UploadCharm(c, "utopic/storage-block-0", "storage-block")
   239  	storageConstraints := map[string]storage.Constraints{
   240  		"data": storage.Constraints{
   241  			Pool:  "foo",
   242  			Count: 1,
   243  			Size:  1024,
   244  		},
   245  	}
   246  
   247  	var cons constraints.Value
   248  	args := params.ServiceDeploy{
   249  		ServiceName: "service",
   250  		CharmUrl:    curl.String(),
   251  		NumUnits:    1,
   252  		Constraints: cons,
   253  		Storage:     storageConstraints,
   254  	}
   255  	results, err := s.serviceApi.ServicesDeploy(params.ServicesDeploy{
   256  		Services: []params.ServiceDeploy{args}},
   257  	)
   258  	c.Assert(err, jc.ErrorIsNil)
   259  	c.Assert(results.Results, gc.HasLen, 1)
   260  	c.Assert(results.Results[0].Error, gc.ErrorMatches, `.* pool "foo" not found`)
   261  }
   262  
   263  func (s *serviceSuite) TestClientServiceDeployWithUnsupportedStoragePool(c *gc.C) {
   264  	registry.RegisterProvider("hostloop", &mockStorageProvider{kind: storage.StorageKindBlock})
   265  	pm := poolmanager.New(state.NewStateSettings(s.State))
   266  	_, err := pm.Create("host-loop-pool", provider.HostLoopProviderType, map[string]interface{}{})
   267  	c.Assert(err, jc.ErrorIsNil)
   268  
   269  	curl, _ := s.UploadCharm(c, "utopic/storage-block-0", "storage-block")
   270  	storageConstraints := map[string]storage.Constraints{
   271  		"data": storage.Constraints{
   272  			Pool:  "host-loop-pool",
   273  			Count: 1,
   274  			Size:  1024,
   275  		},
   276  	}
   277  
   278  	var cons constraints.Value
   279  	args := params.ServiceDeploy{
   280  		ServiceName: "service",
   281  		CharmUrl:    curl.String(),
   282  		NumUnits:    1,
   283  		Constraints: cons,
   284  		Storage:     storageConstraints,
   285  	}
   286  	results, err := s.serviceApi.ServicesDeploy(params.ServicesDeploy{
   287  		Services: []params.ServiceDeploy{args}},
   288  	)
   289  	c.Assert(err, jc.ErrorIsNil)
   290  	c.Assert(results.Results, gc.HasLen, 1)
   291  	c.Assert(results.Results[0].Error, gc.ErrorMatches,
   292  		`.*pool "host-loop-pool" uses storage provider "hostloop" which is not supported for environments of type "dummy"`)
   293  }
   294  
   295  func (s *serviceSuite) TestClientServiceDeployDefaultFilesystemStorage(c *gc.C) {
   296  	setupStoragePool(c, s.State)
   297  	curl, ch := s.UploadCharm(c, "trusty/storage-filesystem-1", "storage-filesystem")
   298  	var cons constraints.Value
   299  	args := params.ServiceDeploy{
   300  		ServiceName: "service",
   301  		CharmUrl:    curl.String(),
   302  		NumUnits:    1,
   303  		Constraints: cons,
   304  	}
   305  	results, err := s.serviceApi.ServicesDeploy(params.ServicesDeploy{
   306  		Services: []params.ServiceDeploy{args}},
   307  	)
   308  	c.Assert(err, jc.ErrorIsNil)
   309  	c.Assert(results, gc.DeepEquals, params.ErrorResults{
   310  		Results: []params.ErrorResult{{Error: nil}},
   311  	})
   312  	svc := apiservertesting.AssertPrincipalServiceDeployed(c, s.State, "service", curl, false, ch, cons)
   313  	storageConstraintsOut, err := svc.StorageConstraints()
   314  	c.Assert(err, jc.ErrorIsNil)
   315  	c.Assert(storageConstraintsOut, gc.DeepEquals, map[string]state.StorageConstraints{
   316  		"data": {
   317  			Count: 1,
   318  			Size:  1024,
   319  			Pool:  "rootfs",
   320  		},
   321  	})
   322  }
   323  
   324  // TODO(wallyworld) - the following charm tests have been moved from the apiserver/client
   325  // package in order to use the fake charm store testing infrastructure. They are legacy tests
   326  // written to use the api client instead of the apiserver logic. They need to be rewritten and
   327  // feature tests added.
   328  
   329  func (s *serviceSuite) TestAddCharm(c *gc.C) {
   330  	var blobs blobs
   331  	s.PatchValue(service.NewStateStorage, func(uuid string, session *mgo.Session) statestorage.Storage {
   332  		storage := statestorage.NewStorage(uuid, session)
   333  		return &recordingStorage{Storage: storage, blobs: &blobs}
   334  	})
   335  
   336  	client := s.APIState.Client()
   337  	// First test the sanity checks.
   338  	err := client.AddCharm(&charm.URL{Name: "nonsense"})
   339  	c.Assert(err, gc.ErrorMatches, `charm URL has invalid schema: ":nonsense-0"`)
   340  	err = client.AddCharm(charm.MustParseURL("local:precise/dummy"))
   341  	c.Assert(err, gc.ErrorMatches, "only charm store charm URLs are supported, with cs: schema")
   342  	err = client.AddCharm(charm.MustParseURL("cs:precise/wordpress"))
   343  	c.Assert(err, gc.ErrorMatches, "charm URL must include revision")
   344  
   345  	// Add a charm, without uploading it to storage, to
   346  	// check that AddCharm does not try to do it.
   347  	charmDir := testcharms.Repo.CharmDir("dummy")
   348  	ident := fmt.Sprintf("%s-%d", charmDir.Meta().Name, charmDir.Revision())
   349  	curl := charm.MustParseURL("cs:quantal/" + ident)
   350  	sch, err := s.State.AddCharm(charmDir, curl, "", ident+"-sha256")
   351  	c.Assert(err, jc.ErrorIsNil)
   352  
   353  	// AddCharm should see the charm in state and not upload it.
   354  	err = client.AddCharm(sch.URL())
   355  	c.Assert(err, jc.ErrorIsNil)
   356  
   357  	c.Assert(blobs.m, gc.HasLen, 0)
   358  
   359  	// Now try adding another charm completely.
   360  	curl, _ = s.UploadCharm(c, "precise/wordpress-3", "wordpress")
   361  	err = client.AddCharm(curl)
   362  	c.Assert(err, jc.ErrorIsNil)
   363  
   364  	// Verify it's in state and it got uploaded.
   365  	storage := statestorage.NewStorage(s.State.EnvironUUID(), s.State.MongoSession())
   366  	sch, err = s.State.Charm(curl)
   367  	c.Assert(err, jc.ErrorIsNil)
   368  	s.assertUploaded(c, storage, sch.StoragePath(), sch.BundleSha256())
   369  }
   370  
   371  func (s *serviceSuite) TestAddCharmWithAuthorization(c *gc.C) {
   372  	// Upload a new charm to the charm store.
   373  	curl, _ := s.UploadCharm(c, "cs:~restricted/precise/wordpress-3", "wordpress")
   374  
   375  	// Change permissions on the new charm such that only bob
   376  	// can read from it.
   377  	s.DischargeUser = "restricted"
   378  	err := s.Srv.NewClient().Put("/"+curl.Path()+"/meta/perm/read", []string{"bob"})
   379  	c.Assert(err, jc.ErrorIsNil)
   380  
   381  	// Try to add a charm to the environment without authorization.
   382  	s.DischargeUser = ""
   383  	err = s.APIState.Client().AddCharm(curl)
   384  	c.Assert(err, gc.ErrorMatches, `cannot retrieve charm "cs:~restricted/precise/wordpress-3": cannot get archive: cannot get discharge from ".*": third party refused discharge: cannot discharge: discharge denied`)
   385  
   386  	tryAs := func(user string) error {
   387  		client := csclient.New(csclient.Params{
   388  			URL: s.Srv.URL(),
   389  		})
   390  		s.DischargeUser = user
   391  		var m *macaroon.Macaroon
   392  		err = client.Get("/delegatable-macaroon", &m)
   393  		c.Assert(err, gc.IsNil)
   394  
   395  		return service.AddCharmWithAuthorization(s.State, params.AddCharmWithAuthorization{URL: curl.String()})
   396  	}
   397  	// Try again with authorization for the wrong user.
   398  	err = tryAs("joe")
   399  	c.Assert(err, gc.ErrorMatches, `cannot retrieve charm "cs:~restricted/precise/wordpress-3": cannot get archive: unauthorized: access denied for user "joe"`)
   400  
   401  	// Try again with the correct authorization this time.
   402  	err = tryAs("bob")
   403  	c.Assert(err, gc.IsNil)
   404  
   405  	// Verify that it has actually been uploaded.
   406  	_, err = s.State.Charm(curl)
   407  	c.Assert(err, gc.IsNil)
   408  }
   409  
   410  func (s *serviceSuite) TestAddCharmConcurrently(c *gc.C) {
   411  	var putBarrier sync.WaitGroup
   412  	var blobs blobs
   413  	s.PatchValue(service.NewStateStorage, func(uuid string, session *mgo.Session) statestorage.Storage {
   414  		storage := statestorage.NewStorage(uuid, session)
   415  		return &recordingStorage{Storage: storage, blobs: &blobs, putBarrier: &putBarrier}
   416  	})
   417  
   418  	client := s.APIState.Client()
   419  	curl, _ := s.UploadCharm(c, "trusty/wordpress-3", "wordpress")
   420  
   421  	// Try adding the same charm concurrently from multiple goroutines
   422  	// to test no "duplicate key errors" are reported (see lp bug
   423  	// #1067979) and also at the end only one charm document is
   424  	// created.
   425  
   426  	var wg sync.WaitGroup
   427  	// We don't add them 1-by-1 because that would allow each goroutine to
   428  	// finish separately without actually synchronizing between them
   429  	putBarrier.Add(10)
   430  	for i := 0; i < 10; i++ {
   431  		wg.Add(1)
   432  		go func(index int) {
   433  			defer wg.Done()
   434  
   435  			c.Assert(client.AddCharm(curl), gc.IsNil, gc.Commentf("goroutine %d", index))
   436  			sch, err := s.State.Charm(curl)
   437  			c.Assert(err, gc.IsNil, gc.Commentf("goroutine %d", index))
   438  			c.Assert(sch.URL(), jc.DeepEquals, curl, gc.Commentf("goroutine %d", index))
   439  		}(i)
   440  	}
   441  	wg.Wait()
   442  
   443  	blobs.Lock()
   444  
   445  	c.Assert(blobs.m, gc.HasLen, 10)
   446  
   447  	// Verify there is only a single uploaded charm remains and it
   448  	// contains the correct data.
   449  	sch, err := s.State.Charm(curl)
   450  	c.Assert(err, jc.ErrorIsNil)
   451  	storagePath := sch.StoragePath()
   452  	c.Assert(blobs.m[storagePath], jc.IsTrue)
   453  	for path, exists := range blobs.m {
   454  		if path != storagePath {
   455  			c.Assert(exists, jc.IsFalse)
   456  		}
   457  	}
   458  
   459  	storage := statestorage.NewStorage(s.State.EnvironUUID(), s.State.MongoSession())
   460  	s.assertUploaded(c, storage, sch.StoragePath(), sch.BundleSha256())
   461  }
   462  
   463  func (s *serviceSuite) assertUploaded(c *gc.C, storage statestorage.Storage, storagePath, expectedSHA256 string) {
   464  	reader, _, err := storage.Get(storagePath)
   465  	c.Assert(err, jc.ErrorIsNil)
   466  	defer reader.Close()
   467  	downloadedSHA256, _, err := utils.ReadSHA256(reader)
   468  	c.Assert(err, jc.ErrorIsNil)
   469  	c.Assert(downloadedSHA256, gc.Equals, expectedSHA256)
   470  }
   471  
   472  func (s *serviceSuite) TestAddCharmOverwritesPlaceholders(c *gc.C) {
   473  	client := s.APIState.Client()
   474  	curl, _ := s.UploadCharm(c, "trusty/wordpress-42", "wordpress")
   475  
   476  	// Add a placeholder with the same charm URL.
   477  	err := s.State.AddStoreCharmPlaceholder(curl)
   478  	c.Assert(err, jc.ErrorIsNil)
   479  	_, err = s.State.Charm(curl)
   480  	c.Assert(err, jc.Satisfies, errors.IsNotFound)
   481  
   482  	// Now try to add the charm, which will convert the placeholder to
   483  	// a pending charm.
   484  	err = client.AddCharm(curl)
   485  	c.Assert(err, jc.ErrorIsNil)
   486  
   487  	// Make sure the document's flags were reset as expected.
   488  	sch, err := s.State.Charm(curl)
   489  	c.Assert(err, jc.ErrorIsNil)
   490  	c.Assert(sch.URL(), jc.DeepEquals, curl)
   491  	c.Assert(sch.IsPlaceholder(), jc.IsFalse)
   492  	c.Assert(sch.IsUploaded(), jc.IsTrue)
   493  }
   494  
   495  type mockStorageProvider struct {
   496  	storage.Provider
   497  	kind storage.StorageKind
   498  }
   499  
   500  func (m *mockStorageProvider) Scope() storage.Scope {
   501  	return storage.ScopeMachine
   502  }
   503  
   504  func (m *mockStorageProvider) Supports(k storage.StorageKind) bool {
   505  	return k == m.kind
   506  }
   507  
   508  func (m *mockStorageProvider) ValidateConfig(*storage.Config) error {
   509  	return nil
   510  }
   511  
   512  type blobs struct {
   513  	sync.Mutex
   514  	m map[string]bool // maps path to added (true), or deleted (false)
   515  }
   516  
   517  // Add adds a path to the list of known paths.
   518  func (b *blobs) Add(path string) {
   519  	b.Lock()
   520  	defer b.Unlock()
   521  	b.check()
   522  	b.m[path] = true
   523  }
   524  
   525  // Remove marks a path as deleted, even if it was not previously Added.
   526  func (b *blobs) Remove(path string) {
   527  	b.Lock()
   528  	defer b.Unlock()
   529  	b.check()
   530  	b.m[path] = false
   531  }
   532  
   533  func (b *blobs) check() {
   534  	if b.m == nil {
   535  		b.m = make(map[string]bool)
   536  	}
   537  }
   538  
   539  type recordingStorage struct {
   540  	statestorage.Storage
   541  	putBarrier *sync.WaitGroup
   542  	blobs      *blobs
   543  }
   544  
   545  func (s *recordingStorage) Put(path string, r io.Reader, size int64) error {
   546  	if s.putBarrier != nil {
   547  		// This goroutine has gotten to Put() so mark it Done() and
   548  		// wait for the other goroutines to get to this point.
   549  		s.putBarrier.Done()
   550  		s.putBarrier.Wait()
   551  	}
   552  	if err := s.Storage.Put(path, r, size); err != nil {
   553  		return errors.Trace(err)
   554  	}
   555  	s.blobs.Add(path)
   556  	return nil
   557  }
   558  
   559  func (s *recordingStorage) Remove(path string) error {
   560  	if err := s.Storage.Remove(path); err != nil {
   561  		return errors.Trace(err)
   562  	}
   563  	s.blobs.Remove(path)
   564  	return nil
   565  }