github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/api/client/charms/localcharmclient_test.go (about) 1 // Copyright 2023 Canonical Ltd. 2 // Licensed under the AGPLv3, see LICENCE file for details. 3 4 package charms_test 5 6 import ( 7 "fmt" 8 "io" 9 "net/http" 10 "regexp" 11 "strings" 12 13 "github.com/juju/charm/v12" 14 "github.com/juju/errors" 15 jc "github.com/juju/testing/checkers" 16 "github.com/juju/version/v2" 17 "go.uber.org/mock/gomock" 18 gc "gopkg.in/check.v1" 19 "gopkg.in/httprequest.v1" 20 21 basemocks "github.com/juju/juju/api/base/mocks" 22 "github.com/juju/juju/api/client/charms" 23 "github.com/juju/juju/api/http/mocks" 24 "github.com/juju/juju/testcharms" 25 "github.com/juju/juju/testing" 26 coretesting "github.com/juju/juju/testing" 27 jujuversion "github.com/juju/juju/version" 28 ) 29 30 type addCharmSuite struct { 31 coretesting.BaseSuite 32 } 33 34 var _ = gc.Suite(&addCharmSuite{}) 35 36 // TestLegacyAddLocalCharm runs the same test as AddLocalCharm, 37 // but backs our client with the legacy http putter 38 func (s *addCharmSuite) TestLegacyAddLocalCharm(c *gc.C) { 39 ctrl := gomock.NewController(c) 40 defer ctrl.Finish() 41 42 mockFacadeCaller := basemocks.NewMockFacadeCaller(ctrl) 43 mockCaller := basemocks.NewMockAPICaller(ctrl) 44 mockHttpDoer := mocks.NewMockHTTPClient(ctrl) 45 reqClient := &httprequest.Client{ 46 BaseURL: "http://somewhere.invalid", 47 Doer: mockHttpDoer, 48 } 49 50 mockCaller.EXPECT().ModelTag().Return(testing.ModelTag, false).AnyTimes() 51 mockFacadeCaller.EXPECT().RawAPICaller().Return(mockCaller).AnyTimes() 52 53 curl, charmArchive := s.testCharm(c) 54 resp := &http.Response{ 55 StatusCode: 200, 56 Header: make(http.Header), 57 Body: io.NopCloser(strings.NewReader(`{"charm-url": "local:quantal/dummy-1"}`)), 58 } 59 resp.Header.Add("Content-Type", "application/json") 60 mockHttpDoer.EXPECT().Do( 61 &httpURLMatcher{"http://somewhere.invalid/charms\\?revision=1&schema=local&series=quantal"}, 62 ).Return(resp, nil).MinTimes(1) 63 64 httpPutter := charms.NewHTTPPutterWithHTTPClient(reqClient) 65 client := charms.NewLocalCharmClientWithFacade(mockFacadeCaller, nil, httpPutter) 66 vers := version.MustParse("2.6.6") 67 // Test the sanity checks first. 68 _, err := client.AddLocalCharm(charm.MustParseURL("ch:wordpress-1"), nil, false, vers) 69 c.Assert(err, gc.ErrorMatches, `expected charm URL with local: schema, got "ch:wordpress-1"`) 70 71 // Upload an archive with its original revision. 72 savedURL, err := client.AddLocalCharm(curl, charmArchive, false, vers) 73 c.Assert(err, jc.ErrorIsNil) 74 c.Assert(savedURL.String(), gc.Equals, curl.String()) 75 76 // Upload a charm directory with changed revision. 77 resp.Body = io.NopCloser(strings.NewReader(`{"charm-url": "local:quantal/dummy-42"}`)) 78 charmDir := testcharms.Repo.ClonedDir(c.MkDir(), "dummy") 79 err = charmDir.SetDiskRevision(42) 80 c.Assert(err, jc.ErrorIsNil) 81 savedURL, err = client.AddLocalCharm(curl, charmDir, false, vers) 82 c.Assert(err, jc.ErrorIsNil) 83 c.Assert(savedURL.Revision, gc.Equals, 42) 84 85 // Upload a charm directory again, revision should be bumped. 86 resp.Body = io.NopCloser(strings.NewReader(`{"charm-url": "local:quantal/dummy-43"}`)) 87 savedURL, err = client.AddLocalCharm(curl, charmDir, false, vers) 88 c.Assert(err, jc.ErrorIsNil) 89 c.Assert(savedURL.String(), gc.Equals, curl.WithRevision(43).String()) 90 } 91 92 func (s *addCharmSuite) TestAddLocalCharm(c *gc.C) { 93 ctrl := gomock.NewController(c) 94 defer ctrl.Finish() 95 96 mockFacadeCaller := basemocks.NewMockFacadeCaller(ctrl) 97 mockCaller := basemocks.NewMockAPICaller(ctrl) 98 mockHttpDoer := mocks.NewMockHTTPClient(ctrl) 99 reqClient := &httprequest.Client{ 100 BaseURL: "http://somewhere.invalid", 101 Doer: mockHttpDoer, 102 } 103 104 mockCaller.EXPECT().ModelTag().Return(testing.ModelTag, false).AnyTimes() 105 mockFacadeCaller.EXPECT().RawAPICaller().Return(mockCaller).AnyTimes() 106 107 curl, charmArchive := s.testCharm(c) 108 resp := &http.Response{ 109 StatusCode: 200, 110 Header: make(http.Header), 111 } 112 resp.Header.Add("Content-Type", "application/json") 113 resp.Header.Add("Juju-Curl", "local:quantal/dummy-1") 114 mockHttpDoer.EXPECT().Do( 115 &httpURLMatcher{fmt.Sprintf("http://somewhere.invalid/model-%s/charms/dummy-[a-f0-9]{7}", testing.ModelTag.Id())}, 116 ).Return(resp, nil).MinTimes(1) 117 118 httpPutter := charms.NewS3PutterWithHTTPClient(reqClient) 119 client := charms.NewLocalCharmClientWithFacade(mockFacadeCaller, nil, httpPutter) 120 vers := version.MustParse("2.6.6") 121 // Test the sanity checks first. 122 _, err := client.AddLocalCharm(charm.MustParseURL("ch:wordpress-1"), nil, false, vers) 123 c.Assert(err, gc.ErrorMatches, `expected charm URL with local: schema, got "ch:wordpress-1"`) 124 125 // Upload an archive with its original revision. 126 savedURL, err := client.AddLocalCharm(curl, charmArchive, false, vers) 127 c.Assert(err, jc.ErrorIsNil) 128 c.Assert(savedURL.String(), gc.Equals, curl.String()) 129 130 // Upload a charm directory with changed revision. 131 resp.Header.Set("Juju-Curl", "local:quantal/dummy-42") 132 charmDir := testcharms.Repo.ClonedDir(c.MkDir(), "dummy") 133 err = charmDir.SetDiskRevision(42) 134 c.Assert(err, jc.ErrorIsNil) 135 savedURL, err = client.AddLocalCharm(curl, charmDir, false, vers) 136 c.Assert(err, jc.ErrorIsNil) 137 c.Assert(savedURL.Revision, gc.Equals, 42) 138 139 // Upload a charm directory again, revision should be bumped. 140 resp.Header.Set("Juju-Curl", "local:quantal/dummy-43") 141 savedURL, err = client.AddLocalCharm(curl, charmDir, false, vers) 142 c.Assert(err, jc.ErrorIsNil) 143 c.Assert(savedURL.String(), gc.Equals, curl.WithRevision(43).String()) 144 } 145 146 func (s *addCharmSuite) TestAddLocalCharmFindingHooksError(c *gc.C) { 147 s.assertAddLocalCharmFailed(c, 148 func(string) (bool, error) { 149 return true, fmt.Errorf("bad zip") 150 }, 151 `bad zip`) 152 } 153 154 func (s *addCharmSuite) TestAddLocalCharmNoHooks(c *gc.C) { 155 s.assertAddLocalCharmFailed(c, 156 func(string) (bool, error) { 157 return false, nil 158 }, 159 `invalid charm \"dummy\": has no hooks nor dispatch file`) 160 } 161 162 func (s *addCharmSuite) TestAddLocalCharmWithLXDProfile(c *gc.C) { 163 ctrl := gomock.NewController(c) 164 defer ctrl.Finish() 165 166 mockFacadeCaller := basemocks.NewMockFacadeCaller(ctrl) 167 mockCaller := basemocks.NewMockAPICaller(ctrl) 168 mockHttpDoer := mocks.NewMockHTTPClient(ctrl) 169 reqClient := &httprequest.Client{ 170 BaseURL: "http://somewhere.invalid", 171 Doer: mockHttpDoer, 172 } 173 174 mockCaller.EXPECT().ModelTag().Return(testing.ModelTag, false).AnyTimes() 175 mockFacadeCaller.EXPECT().RawAPICaller().Return(mockCaller).AnyTimes() 176 177 resp := &http.Response{ 178 StatusCode: 200, 179 Header: make(http.Header), 180 } 181 resp.Header.Add("Content-Type", "application/json") 182 resp.Header.Add("Juju-Curl", "local:quantal/lxd-profile-0") 183 mockHttpDoer.EXPECT().Do( 184 &httpURLMatcher{fmt.Sprintf("http://somewhere.invalid/model-%s/charms/lxd-profile-[a-f0-9]{7}", testing.ModelTag.Id())}, 185 ).Return(resp, nil).MinTimes(1) 186 187 httpPutter := charms.NewS3PutterWithHTTPClient(reqClient) 188 client := charms.NewLocalCharmClientWithFacade(mockFacadeCaller, nil, httpPutter) 189 190 charmArchive := testcharms.Repo.CharmArchive(c.MkDir(), "lxd-profile") 191 curl := charm.MustParseURL( 192 fmt.Sprintf("local:quantal/%s-%d", charmArchive.Meta().Name, charmArchive.Revision()), 193 ) 194 195 vers := version.MustParse("2.6.6") 196 savedURL, err := client.AddLocalCharm(curl, charmArchive, false, vers) 197 c.Assert(err, jc.ErrorIsNil) 198 c.Assert(savedURL.String(), gc.Equals, "local:quantal/lxd-profile-0") 199 } 200 201 func (s *addCharmSuite) TestAddLocalCharmWithInvalidLXDProfile(c *gc.C) { 202 ctrl := gomock.NewController(c) 203 defer ctrl.Finish() 204 205 mockFacadeCaller := basemocks.NewMockFacadeCaller(ctrl) 206 mockHttpDoer := mocks.NewMockHTTPClient(ctrl) 207 reqClient := &httprequest.Client{ 208 BaseURL: "http://somewhere.invalid", 209 Doer: mockHttpDoer, 210 } 211 httpPutter := charms.NewS3PutterWithHTTPClient(reqClient) 212 client := charms.NewLocalCharmClientWithFacade(mockFacadeCaller, nil, httpPutter) 213 214 charmArchive := testcharms.Repo.CharmArchive(c.MkDir(), "lxd-profile-fail") 215 curl := charm.MustParseURL( 216 fmt.Sprintf("local:quantal/%s-%d", charmArchive.Meta().Name, charmArchive.Revision()), 217 ) 218 219 vers := version.MustParse("2.6.6") 220 _, err := client.AddLocalCharm(curl, charmArchive, false, vers) 221 c.Assert(err, gc.ErrorMatches, "invalid lxd-profile.yaml: contains device type \"unix-disk\"") 222 } 223 224 func (s *addCharmSuite) TestAddLocalCharmWithValidLXDProfileWithForceSucceeds(c *gc.C) { 225 s.testAddLocalCharmWithForceSucceeds("lxd-profile", c) 226 } 227 228 func (s *addCharmSuite) TestAddLocalCharmWithInvalidLXDProfileWithForceSucceeds(c *gc.C) { 229 s.testAddLocalCharmWithForceSucceeds("lxd-profile-fail", c) 230 } 231 232 func (s *addCharmSuite) testAddLocalCharmWithForceSucceeds(name string, c *gc.C) { 233 ctrl := gomock.NewController(c) 234 defer ctrl.Finish() 235 236 mockFacadeCaller := basemocks.NewMockFacadeCaller(ctrl) 237 mockCaller := basemocks.NewMockAPICaller(ctrl) 238 mockHttpDoer := mocks.NewMockHTTPClient(ctrl) 239 reqClient := &httprequest.Client{ 240 BaseURL: "http://somewhere.invalid", 241 Doer: mockHttpDoer, 242 } 243 244 mockCaller.EXPECT().ModelTag().Return(testing.ModelTag, false).AnyTimes() 245 mockFacadeCaller.EXPECT().RawAPICaller().Return(mockCaller).AnyTimes() 246 247 resp := &http.Response{ 248 StatusCode: 200, 249 Header: make(http.Header), 250 } 251 resp.Header.Add("Content-Type", "application/json") 252 resp.Header.Add("Juju-Curl", "local:quantal/lxd-profile-0") 253 mockHttpDoer.EXPECT().Do( 254 &httpURLMatcher{fmt.Sprintf("http://somewhere.invalid/model-%s/charms/lxd-profile-[a-f0-9]{7}", testing.ModelTag.Id())}, 255 ).Return(resp, nil).MinTimes(1) 256 257 httpPutter := charms.NewS3PutterWithHTTPClient(reqClient) 258 client := charms.NewLocalCharmClientWithFacade(mockFacadeCaller, nil, httpPutter) 259 260 charmArchive := testcharms.Repo.CharmArchive(c.MkDir(), "lxd-profile") 261 curl := charm.MustParseURL( 262 fmt.Sprintf("local:quantal/%s-%d", charmArchive.Meta().Name, charmArchive.Revision()), 263 ) 264 265 vers := version.MustParse("2.6.6") 266 savedURL, err := client.AddLocalCharm(curl, charmArchive, false, vers) 267 c.Assert(err, jc.ErrorIsNil) 268 c.Assert(savedURL.String(), gc.Equals, "local:quantal/lxd-profile-0") 269 } 270 271 func (s *addCharmSuite) assertAddLocalCharmFailed(c *gc.C, f func(string) (bool, error), msg string) { 272 ctrl := gomock.NewController(c) 273 defer ctrl.Finish() 274 275 curl, ch := s.testCharm(c) 276 s.PatchValue(charms.HasHooksOrDispatch, f) 277 278 mockFacadeCaller := basemocks.NewMockFacadeCaller(ctrl) 279 mockHttpDoer := mocks.NewMockHTTPClient(ctrl) 280 reqClient := &httprequest.Client{ 281 BaseURL: "http://somewhere.invalid", 282 Doer: mockHttpDoer, 283 } 284 httpPutter := charms.NewS3PutterWithHTTPClient(reqClient) 285 client := charms.NewLocalCharmClientWithFacade(mockFacadeCaller, nil, httpPutter) 286 vers := version.MustParse("2.6.6") 287 _, err := client.AddLocalCharm(curl, ch, false, vers) 288 c.Assert(err, gc.ErrorMatches, msg) 289 } 290 291 func (s *addCharmSuite) TestAddLocalCharmDefinitelyWithHooks(c *gc.C) { 292 ctrl := gomock.NewController(c) 293 defer ctrl.Finish() 294 295 curl, ch := s.testCharm(c) 296 s.PatchValue(charms.HasHooksOrDispatch, func(string) (bool, error) { 297 return true, nil 298 }) 299 mockFacadeCaller := basemocks.NewMockFacadeCaller(ctrl) 300 mockCaller := basemocks.NewMockAPICaller(ctrl) 301 mockHttpDoer := mocks.NewMockHTTPClient(ctrl) 302 reqClient := &httprequest.Client{ 303 BaseURL: "http://somewhere.invalid", 304 Doer: mockHttpDoer, 305 } 306 307 mockCaller.EXPECT().ModelTag().Return(testing.ModelTag, false).AnyTimes() 308 mockFacadeCaller.EXPECT().RawAPICaller().Return(mockCaller).AnyTimes() 309 310 resp := &http.Response{ 311 StatusCode: 200, 312 Header: make(http.Header), 313 } 314 resp.Header.Add("Content-Type", "application/json") 315 resp.Header.Add("Juju-Curl", "local:quantal/dummy-1") 316 mockHttpDoer.EXPECT().Do( 317 &httpURLMatcher{fmt.Sprintf("http://somewhere.invalid/model-%s/charms/dummy-[a-f0-9]{7}", testing.ModelTag.Id())}, 318 ).Return(resp, nil).MinTimes(1) 319 320 httpPutter := charms.NewS3PutterWithHTTPClient(reqClient) 321 client := charms.NewLocalCharmClientWithFacade(mockFacadeCaller, nil, httpPutter) 322 323 vers := version.MustParse("2.6.6") 324 savedCURL, err := client.AddLocalCharm(curl, ch, false, vers) 325 c.Assert(err, jc.ErrorIsNil) 326 c.Assert(savedCURL.String(), gc.Equals, curl.String()) 327 } 328 329 func (s *addCharmSuite) testCharm(c *gc.C) (*charm.URL, charm.Charm) { 330 charmArchive := testcharms.Repo.CharmArchive(c.MkDir(), "dummy") 331 curl := charm.MustParseURL( 332 fmt.Sprintf("local:quantal/%s-%d", charmArchive.Meta().Name, charmArchive.Revision()), 333 ) 334 return curl, charmArchive 335 } 336 337 func (s *addCharmSuite) TestAddLocalCharmError(c *gc.C) { 338 ctrl := gomock.NewController(c) 339 defer ctrl.Finish() 340 341 curl, charmArchive := s.testCharm(c) 342 s.PatchValue(charms.HasHooksOrDispatch, func(string) (bool, error) { 343 return true, nil 344 }) 345 mockFacadeCaller := basemocks.NewMockFacadeCaller(ctrl) 346 mockCaller := basemocks.NewMockAPICaller(ctrl) 347 mockHttpDoer := mocks.NewMockHTTPClient(ctrl) 348 reqClient := &httprequest.Client{ 349 BaseURL: "http://somewhere.invalid", 350 Doer: mockHttpDoer, 351 } 352 353 mockCaller.EXPECT().ModelTag().Return(testing.ModelTag, false).AnyTimes() 354 mockFacadeCaller.EXPECT().RawAPICaller().Return(mockCaller).AnyTimes() 355 resp := &http.Response{ 356 StatusCode: 200, 357 Header: make(http.Header), 358 } 359 resp.Header.Add("Content-Type", "application/json") 360 resp.Header.Add("Juju-Curl", "local:quantal/dummy-1") 361 mockHttpDoer.EXPECT().Do( 362 &httpURLMatcher{fmt.Sprintf("http://somewhere.invalid/model-%s/charms/dummy-[a-f0-9]{7}", testing.ModelTag.Id())}, 363 ).Return(nil, errors.New("boom")).MinTimes(1) 364 365 httpPutter := charms.NewS3PutterWithHTTPClient(reqClient) 366 client := charms.NewLocalCharmClientWithFacade(mockFacadeCaller, nil, httpPutter) 367 368 vers := version.MustParse("2.6.6") 369 _, err := client.AddLocalCharm(curl, charmArchive, false, vers) 370 c.Assert(err, gc.ErrorMatches, `.*boom$`) 371 } 372 373 func (s *addCharmSuite) TestMinVersionLocalCharm(c *gc.C) { 374 tests := []minverTest{ 375 {"2.0.0", "1.0.0", false, true}, 376 {"1.0.0", "2.0.0", false, false}, 377 {"1.25.0", "1.24.0", false, true}, 378 {"1.24.0", "1.25.0", false, false}, 379 {"1.25.1", "1.25.0", false, true}, 380 {"1.25.0", "1.25.1", false, false}, 381 {"1.25.0", "1.25.0", false, true}, 382 {"1.25.0", "1.25-alpha1", false, true}, 383 {"1.25-alpha1", "1.25.0", false, true}, 384 {"2.0.0", "1.0.0", true, true}, 385 {"1.0.0", "2.0.0", true, false}, 386 {"1.25.0", "1.24.0", true, true}, 387 {"1.24.0", "1.25.0", true, false}, 388 {"1.25.1", "1.25.0", true, true}, 389 {"1.25.0", "1.25.1", true, false}, 390 {"1.25.0", "1.25.0", true, true}, 391 {"1.25.0", "1.25-alpha1", true, true}, 392 {"1.25-alpha1", "1.25.0", true, true}, 393 } 394 for _, t := range tests { 395 testMinVer(t, c) 396 } 397 } 398 399 type minverTest struct { 400 juju string 401 charm string 402 force bool 403 ok bool 404 } 405 406 func testMinVer(t minverTest, c *gc.C) { 407 ctrl := gomock.NewController(c) 408 defer ctrl.Finish() 409 410 mockFacadeCaller := basemocks.NewMockFacadeCaller(ctrl) 411 mockCaller := basemocks.NewMockAPICaller(ctrl) 412 mockHttpDoer := mocks.NewMockHTTPClient(ctrl) 413 reqClient := &httprequest.Client{ 414 BaseURL: "http://somewhere.invalid", 415 Doer: mockHttpDoer, 416 } 417 418 mockCaller.EXPECT().ModelTag().Return(testing.ModelTag, false).AnyTimes() 419 mockFacadeCaller.EXPECT().RawAPICaller().Return(mockCaller).AnyTimes() 420 421 resp := &http.Response{ 422 StatusCode: 200, 423 Header: make(http.Header), 424 } 425 resp.Header.Add("Content-Type", "application/json") 426 resp.Header.Add("Juju-Curl", "local:quantal/dummy-1") 427 mockHttpDoer.EXPECT().Do( 428 &httpURLMatcher{fmt.Sprintf("http://somewhere.invalid/model-%s/charms/dummy-[a-f0-9]{7}", testing.ModelTag.Id())}, 429 ).Return(resp, nil).AnyTimes() 430 431 httpPutter := charms.NewS3PutterWithHTTPClient(reqClient) 432 client := charms.NewLocalCharmClientWithFacade(mockFacadeCaller, nil, httpPutter) 433 434 charmMinVer := version.MustParse(t.charm) 435 jujuVer := version.MustParse(t.juju) 436 437 charmArchive := testcharms.Repo.CharmArchive(c.MkDir(), "dummy") 438 curl := charm.MustParseURL( 439 fmt.Sprintf("local:quantal/%s-%d", charmArchive.Meta().Name, charmArchive.Revision()), 440 ) 441 charmArchive.Meta().MinJujuVersion = charmMinVer 442 443 _, err := client.AddLocalCharm(curl, charmArchive, t.force, jujuVer) 444 445 if t.ok { 446 if err != nil { 447 c.Errorf("Unexpected non-nil error for jujuver %v, minver %v: %#v", t.juju, t.charm, err) 448 } 449 } else { 450 if err == nil { 451 c.Errorf("Unexpected nil error for jujuver %v, minver %v", t.juju, t.charm) 452 } else if !jujuversion.IsMinVersionError(err) { 453 c.Errorf("Wrong error for jujuver %v, minver %v: expected minVersionError, got: %#v", t.juju, t.charm, err) 454 } 455 } 456 } 457 458 type httpURLMatcher struct { 459 expectedURL string 460 } 461 462 func (m httpURLMatcher) Matches(x interface{}) bool { 463 req, ok := x.(*http.Request) 464 if !ok { 465 return false 466 } 467 match, err := regexp.MatchString(m.expectedURL, req.URL.String()) 468 if err != nil { 469 panic("httpURLMatcher regexp invalid") 470 } 471 return match 472 } 473 474 func (m httpURLMatcher) String() string { 475 return fmt.Sprintf("Request URL to match %s", m.expectedURL) 476 }