github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/worker/caasoperator/caasoperator_test.go (about) 1 // Copyright 2017 Canonical Ltd. 2 // Licensed under the AGPLv3, see LICENCE file for details. 3 4 package caasoperator_test 5 6 import ( 7 "net/url" 8 "os" 9 "path/filepath" 10 "time" 11 12 "github.com/juju/clock" 13 "github.com/juju/clock/testclock" 14 "github.com/juju/errors" 15 "github.com/juju/loggo" 16 "github.com/juju/names/v5" 17 "github.com/juju/retry" 18 "github.com/juju/testing" 19 jc "github.com/juju/testing/checkers" 20 "github.com/juju/utils/v3" 21 "github.com/juju/utils/v3/symlink" 22 "github.com/juju/worker/v3" 23 "github.com/juju/worker/v3/workertest" 24 gc "gopkg.in/check.v1" 25 26 agenttools "github.com/juju/juju/agent/tools" 27 apiuniter "github.com/juju/juju/api/agent/uniter" 28 "github.com/juju/juju/caas" 29 "github.com/juju/juju/caas/kubernetes/provider/exec" 30 "github.com/juju/juju/core/leadership" 31 "github.com/juju/juju/core/life" 32 "github.com/juju/juju/core/status" 33 "github.com/juju/juju/core/watcher/watchertest" 34 "github.com/juju/juju/downloader" 35 "github.com/juju/juju/juju/sockets" 36 "github.com/juju/juju/testcharms" 37 coretesting "github.com/juju/juju/testing" 38 "github.com/juju/juju/worker/caasoperator" 39 "github.com/juju/juju/worker/uniter" 40 "github.com/juju/juju/worker/uniter/remotestate" 41 runnertesting "github.com/juju/juju/worker/uniter/runner/testing" 42 ) 43 44 type WorkerSuite struct { 45 testing.IsolationSuite 46 47 clock *testclock.Clock 48 config caasoperator.Config 49 unitsChanges chan []string 50 containerChanges chan []string 51 appChanges chan struct{} 52 appWatched chan struct{} 53 unitRemoved chan struct{} 54 client fakeClient 55 charmDownloader fakeDownloader 56 charmSHA256 string 57 uniterParams *uniter.UniterParams 58 leadershipTrackerFunc func(unitTag names.UnitTag) leadership.TrackerWorker 59 uniterFacadeFunc func(unitTag names.UnitTag) *apiuniter.State 60 resourceFacadeFunc func(unitTag names.UnitTag) (*apiuniter.ResourcesFacadeClient, error) 61 payloadFacadeFunc func() *apiuniter.PayloadFacadeClient 62 runListenerSocketFunc func(*uniter.SocketConfig) (*sockets.Socket, error) 63 mockExecutor *mockExecutor 64 } 65 66 var _ = gc.Suite(&WorkerSuite{}) 67 68 func sockPath(c *gc.C) sockets.Socket { 69 sockPath := filepath.Join(c.MkDir(), "test.listener") 70 return sockets.Socket{Address: sockPath, Network: "unix"} 71 } 72 73 func (s *WorkerSuite) SetUpTest(c *gc.C) { 74 s.IsolationSuite.SetUpTest(c) 75 76 // Create a charm archive, and compute its SHA256 hash 77 // for comparison in the tests. 78 fakeDownloadDir := c.MkDir() 79 s.charmDownloader = fakeDownloader{ 80 path: testcharms.Repo.CharmArchivePath( 81 fakeDownloadDir, 82 "../kubernetes/gitlab", 83 ), 84 } 85 charmSHA256, _, err := utils.ReadFileSHA256(s.charmDownloader.path) 86 c.Assert(err, jc.ErrorIsNil) 87 s.charmSHA256 = charmSHA256 88 89 s.clock = testclock.NewClock(time.Time{}) 90 s.appWatched = make(chan struct{}, 1) 91 s.unitRemoved = make(chan struct{}, 1) 92 s.client = fakeClient{ 93 applicationWatched: s.appWatched, 94 unitRemoved: s.unitRemoved, 95 life: life.Alive, 96 } 97 s.unitsChanges = make(chan []string) 98 s.containerChanges = make(chan []string) 99 s.appChanges = make(chan struct{}) 100 s.client.unitsWatcher = watchertest.NewMockStringsWatcher(s.unitsChanges) 101 s.client.containerWatcher = watchertest.NewMockStringsWatcher(s.containerChanges) 102 s.client.watcher = watchertest.NewMockNotifyWatcher(s.appChanges) 103 s.charmDownloader.ResetCalls() 104 s.uniterParams = &uniter.UniterParams{ 105 Logger: loggo.GetLogger("uniter"), 106 } 107 s.leadershipTrackerFunc = func(unitTag names.UnitTag) leadership.TrackerWorker { 108 return &runnertesting.FakeTracker{} 109 } 110 s.uniterFacadeFunc = func(unitTag names.UnitTag) *apiuniter.State { 111 return &apiuniter.State{} 112 } 113 s.resourceFacadeFunc = func(unitTag names.UnitTag) (*apiuniter.ResourcesFacadeClient, error) { 114 return &apiuniter.ResourcesFacadeClient{}, nil 115 } 116 s.payloadFacadeFunc = func() *apiuniter.PayloadFacadeClient { 117 return &apiuniter.PayloadFacadeClient{} 118 } 119 s.runListenerSocketFunc = func(*uniter.SocketConfig) (*sockets.Socket, error) { 120 socket := sockPath(c) 121 return &socket, nil 122 } 123 s.mockExecutor = &mockExecutor{} 124 125 s.config = caasoperator.Config{ 126 Application: "gitlab", 127 CharmGetter: &s.client, 128 Clock: s.clock, 129 DataDir: c.MkDir(), 130 ProfileDir: c.MkDir(), 131 Downloader: &s.charmDownloader, 132 StatusSetter: &s.client, 133 ApplicationWatcher: &s.client, 134 ContainerStartWatcher: &s.client, 135 UnitGetter: &s.client, 136 UnitRemover: &s.client, 137 VersionSetter: &s.client, 138 UniterParams: s.uniterParams, 139 LeadershipTrackerFunc: s.leadershipTrackerFunc, 140 UniterFacadeFunc: s.uniterFacadeFunc, 141 ResourcesFacadeFunc: s.resourceFacadeFunc, 142 PayloadFacadeFunc: s.payloadFacadeFunc, 143 RunListenerSocketFunc: s.runListenerSocketFunc, 144 StartUniterFunc: func(runner *worker.Runner, params *uniter.UniterParams) error { return nil }, 145 ExecClientGetter: func() (exec.Executor, error) { return s.mockExecutor, nil }, 146 Logger: loggo.GetLogger("operator"), 147 } 148 149 agentBinaryDir := agenttools.ToolsDir(s.config.DataDir, "application-gitlab") 150 err = os.MkdirAll(agentBinaryDir, 0755) 151 c.Assert(err, jc.ErrorIsNil) 152 err = os.WriteFile(filepath.Join(s.config.DataDir, "tools", "jujud"), []byte("jujud"), 0755) 153 c.Assert(err, jc.ErrorIsNil) 154 } 155 156 func (s *WorkerSuite) TestValidateConfig(c *gc.C) { 157 s.testValidateConfig(c, func(config *caasoperator.Config) { 158 config.Application = "" 159 }, `application name "" not valid`) 160 161 s.testValidateConfig(c, func(config *caasoperator.Config) { 162 config.ProfileDir = "" 163 }, `missing ProfileDir not valid`) 164 165 s.testValidateConfig(c, func(config *caasoperator.Config) { 166 config.ApplicationWatcher = nil 167 }, `missing ApplicationWatcher not valid`) 168 169 s.testValidateConfig(c, func(config *caasoperator.Config) { 170 config.UnitGetter = nil 171 }, `missing UnitGetter not valid`) 172 173 s.testValidateConfig(c, func(config *caasoperator.Config) { 174 config.UnitRemover = nil 175 }, `missing UnitRemover not valid`) 176 177 s.testValidateConfig(c, func(config *caasoperator.Config) { 178 config.LeadershipTrackerFunc = nil 179 }, `missing LeadershipTrackerFunc not valid`) 180 181 s.testValidateConfig(c, func(config *caasoperator.Config) { 182 config.UniterFacadeFunc = nil 183 }, `missing UniterFacadeFunc not valid`) 184 185 s.testValidateConfig(c, func(config *caasoperator.Config) { 186 config.ResourcesFacadeFunc = nil 187 }, `missing ResourcesFacadeFunc not valid`) 188 189 s.testValidateConfig(c, func(config *caasoperator.Config) { 190 config.PayloadFacadeFunc = nil 191 }, `missing PayloadFacadeFunc not valid`) 192 193 s.testValidateConfig(c, func(config *caasoperator.Config) { 194 config.UniterParams = nil 195 }, `missing UniterParams not valid`) 196 197 s.testValidateConfig(c, func(config *caasoperator.Config) { 198 config.CharmGetter = nil 199 }, `missing CharmGetter not valid`) 200 201 s.testValidateConfig(c, func(config *caasoperator.Config) { 202 config.Clock = nil 203 }, `missing Clock not valid`) 204 205 s.testValidateConfig(c, func(config *caasoperator.Config) { 206 config.DataDir = "" 207 }, `missing DataDir not valid`) 208 209 s.testValidateConfig(c, func(config *caasoperator.Config) { 210 config.Downloader = nil 211 }, `missing Downloader not valid`) 212 213 s.testValidateConfig(c, func(config *caasoperator.Config) { 214 config.StatusSetter = nil 215 }, `missing StatusSetter not valid`) 216 217 s.testValidateConfig(c, func(config *caasoperator.Config) { 218 config.VersionSetter = nil 219 }, `missing VersionSetter not valid`) 220 221 s.testValidateConfig(c, func(config *caasoperator.Config) { 222 config.Logger = nil 223 }, `missing Logger not valid`) 224 225 } 226 227 func (s *WorkerSuite) testValidateConfig(c *gc.C, f func(*caasoperator.Config), expect string) { 228 config := s.config 229 f(&config) 230 w, err := caasoperator.NewWorker(config) 231 if err == nil { 232 workertest.DirtyKill(c, w) 233 } 234 c.Check(err, gc.ErrorMatches, expect) 235 } 236 237 func (s *WorkerSuite) TestStartStop(c *gc.C) { 238 w, err := caasoperator.NewWorker(s.config) 239 c.Assert(err, jc.ErrorIsNil) 240 workertest.CheckAlive(c, w) 241 242 retryCallArgs := retry.CallArgs{ 243 Clock: clock.WallClock, 244 MaxDuration: 500 * time.Millisecond, 245 Delay: 100 * time.Millisecond, 246 Func: func() error { 247 _, err = os.Stat(filepath.Join(s.config.ProfileDir, "juju-introspection.sh")) 248 return err 249 }, 250 } 251 err = retry.Call(retryCallArgs) 252 if err != nil { 253 c.Fatal("missing introspection script") 254 } 255 workertest.CleanKill(c, w) 256 } 257 258 func (s *WorkerSuite) TestWorkerDownloadsCharm(c *gc.C) { 259 uniterStarted := make(chan struct{}) 260 s.config.StartUniterFunc = func(runner *worker.Runner, params *uniter.UniterParams) error { 261 c.Assert(params.UnitTag.Id(), gc.Equals, "gitlab/0") 262 close(uniterStarted) 263 return nil 264 } 265 266 w, err := caasoperator.NewWorker(s.config) 267 c.Assert(err, jc.ErrorIsNil) 268 defer workertest.CleanKill(c, w) 269 270 select { 271 case s.appChanges <- struct{}{}: 272 case <-time.After(coretesting.LongWait): 273 c.Fatal("timed out sending application change") 274 } 275 select { 276 case s.unitsChanges <- []string{"gitlab/0"}: 277 case <-time.After(coretesting.LongWait): 278 c.Fatal("timed out sending unit change") 279 } 280 select { 281 case <-s.appWatched: 282 case <-time.After(coretesting.LongWait): 283 c.Fatal("timed out waiting for application to be watched") 284 } 285 select { 286 case <-uniterStarted: 287 case <-time.After(coretesting.LongWait): 288 c.Fatalf("timeout while waiting for uniter to start") 289 } 290 291 s.client.CheckCallNames(c, "Charm", "SetStatus", "SetVersion", "WatchUnits", "WatchContainerStart", "SetStatus", "Watch", "Charm", "Life") 292 s.client.CheckCall(c, 0, "Charm", "gitlab") 293 s.client.CheckCall(c, 2, "SetVersion", "gitlab", coretesting.CurrentVersion()) 294 s.client.CheckCall(c, 3, "WatchUnits", "gitlab") 295 s.client.CheckCall(c, 4, "WatchContainerStart", "gitlab", "(?:juju-pod-init|)") 296 s.client.CheckCall(c, 6, "Watch", "gitlab") 297 298 s.charmDownloader.CheckCallNames(c, "Download") 299 downloadArgs := s.charmDownloader.Calls()[0].Args 300 c.Assert(downloadArgs, gc.HasLen, 1) 301 c.Assert(downloadArgs[0], gc.FitsTypeOf, downloader.Request{}) 302 downloadRequest := downloadArgs[0].(downloader.Request) 303 c.Assert(downloadRequest.Abort, gc.NotNil) 304 c.Assert(downloadRequest.Verify, gc.NotNil) 305 306 // fakeClient.Charm returns the SHA256 sum of fakeCharmContent. 307 fakeCharmPath := filepath.Join(c.MkDir(), "fake.charm") 308 err = os.WriteFile(fakeCharmPath, fakeCharmContent, 0644) 309 c.Assert(err, jc.ErrorIsNil) 310 f, err := os.Open(fakeCharmPath) 311 c.Assert(err, jc.ErrorIsNil) 312 defer f.Close() 313 err = downloadRequest.Verify(f) 314 c.Assert(err, jc.ErrorIsNil) 315 316 downloadRequest.Abort = nil 317 downloadRequest.Verify = nil 318 agentDir := filepath.Join(s.config.DataDir, "agents", "application-gitlab") 319 c.Assert( 320 downloadRequest, 321 jc.DeepEquals, 322 downloader.Request{ 323 ArchiveSha256: fakeCharmSHA256, 324 URL: &url.URL{Scheme: "ch", Opaque: "gitlab-1"}, 325 TargetDir: filepath.Join(agentDir, "state", "bundles", "downloads"), 326 }, 327 ) 328 329 // The download directory should have been removed. 330 _, err = os.Stat(downloadRequest.TargetDir) 331 c.Assert(err, jc.Satisfies, os.IsNotExist) 332 333 // The charm archive should have been unpacked into <data-dir>/charm. 334 charmDir := filepath.Join(agentDir, "charm") 335 _, err = os.Stat(filepath.Join(charmDir, "metadata.yaml")) 336 c.Assert(err, jc.ErrorIsNil) 337 338 } 339 340 func (s *WorkerSuite) assertUniterStarted(c *gc.C) worker.Worker { 341 ch := make(chan struct{}) 342 s.config.StartUniterFunc = func(runner *worker.Runner, params *uniter.UniterParams) error { 343 defer close(ch) 344 c.Assert(params.UnitTag.Id(), gc.Equals, "gitlab/0") 345 return nil 346 } 347 348 w, err := caasoperator.NewWorker(s.config) 349 c.Assert(err, jc.ErrorIsNil) 350 351 select { 352 case s.appChanges <- struct{}{}: 353 case <-time.After(coretesting.LongWait): 354 c.Fatal("timed out sending application change") 355 } 356 select { 357 case s.unitsChanges <- []string{"gitlab/0"}: 358 case <-time.After(coretesting.LongWait): 359 c.Fatal("timed out sending unit change") 360 } 361 362 select { 363 case <-ch: 364 case <-time.After(coretesting.ShortWait): 365 c.Fatal("timed out waiting for start uniter to be called for unit gitlab/0") 366 } 367 return w 368 } 369 370 func (s *WorkerSuite) TestWorkerSetsStatus(c *gc.C) { 371 w, err := caasoperator.NewWorker(s.config) 372 c.Assert(err, jc.ErrorIsNil) 373 defer workertest.CleanKill(c, w) 374 375 for attempt := coretesting.LongAttempt.Start(); attempt.Next(); { 376 if len(s.client.Calls()) == 7 { 377 break 378 } 379 } 380 s.client.CheckCallNames(c, "Charm", "SetStatus", "SetVersion", "WatchUnits", "WatchContainerStart", "SetStatus", "Watch") 381 s.client.CheckCall(c, 1, "SetStatus", "gitlab", status.Maintenance, "downloading charm (ch:gitlab-1)", map[string]interface{}(nil)) 382 } 383 384 func (s *WorkerSuite) TestWatcherFailureStopsWorker(c *gc.C) { 385 w, err := caasoperator.NewWorker(s.config) 386 c.Assert(err, jc.ErrorIsNil) 387 defer workertest.DirtyKill(c, w) 388 389 s.client.unitsWatcher.KillErr(errors.New("splat")) 390 err = workertest.CheckKilled(c, w) 391 c.Assert(err, gc.ErrorMatches, "splat") 392 } 393 394 func (s *WorkerSuite) TestRemovedUnit(c *gc.C) { 395 w := s.assertUniterStarted(c) 396 defer workertest.CleanKill(c, w) 397 398 s.client.ResetCalls() 399 s.client.life = life.Dead 400 select { 401 case s.unitsChanges <- []string{"gitlab/0"}: 402 case <-time.After(coretesting.LongWait): 403 c.Fatal("timed out sending unit change") 404 } 405 406 select { 407 case <-s.unitRemoved: 408 case <-time.After(coretesting.LongWait): 409 c.Fatal("timed out waiting for unit to be removed") 410 } 411 s.client.CheckCallNames(c, "Life", "RemoveUnit") 412 s.client.CheckCall(c, 0, "Life", "gitlab/0") 413 s.client.CheckCall(c, 1, "RemoveUnit", "gitlab/0") 414 } 415 416 func (s *WorkerSuite) TestRemovedApplication(c *gc.C) { 417 s.client.SetErrors(errors.NotFoundf("app")) 418 w, err := caasoperator.NewWorker(s.config) 419 c.Assert(err, jc.ErrorIsNil) 420 defer workertest.DirtyKill(c, w) 421 422 err = workertest.CheckKilled(c, w) 423 c.Assert(err, gc.ErrorMatches, "agent should be terminated") 424 } 425 426 func (s *WorkerSuite) TestMakeAgentSymlinks(c *gc.C) { 427 w, err := caasoperator.NewWorker(s.config) 428 c.Assert(err, jc.ErrorIsNil) 429 defer workertest.CleanKill(c, w) 430 431 unitTag := names.NewUnitTag("gitlab/0") 432 op := w.(*caasoperator.CaasOperator) 433 unitDir := filepath.Join(op.GetDataDir(), "agents", unitTag.String()) 434 err = os.MkdirAll(unitDir, 0755) 435 c.Assert(err, jc.ErrorIsNil) 436 437 unitCharmLegacySymlink := filepath.Join(unitDir, "charm") 438 fakeAppDir := c.MkDir() 439 err = symlink.New(fakeAppDir, unitCharmLegacySymlink) 440 c.Assert(err, jc.ErrorIsNil) 441 assertSymlinkExist(c, unitCharmLegacySymlink) 442 443 err = op.MakeAgentSymlinks(unitTag) 444 c.Assert(err, jc.ErrorIsNil) 445 assertSymlinkNotExist(c, unitCharmLegacySymlink) 446 } 447 448 func assertSymlinkExist(c *gc.C, path string) { 449 symlinkExists, err := symlink.IsSymlink(path) 450 c.Assert(err, jc.ErrorIsNil) 451 c.Assert(symlinkExists, jc.IsTrue) 452 } 453 454 func assertSymlinkNotExist(c *gc.C, path string) { 455 _, err := symlink.IsSymlink(path) 456 c.Assert(errors.Cause(err), jc.Satisfies, os.IsNotExist) 457 } 458 459 func (s *WorkerSuite) TestContainerStart(c *gc.C) { 460 uniterStarted := make(chan struct{}) 461 uniterGotRunning := make(chan struct{}) 462 s.mockExecutor.status = exec.Status{ 463 PodName: "gitlab-ffff", 464 ContainerStatus: []exec.ContainerStatus{{ 465 Name: "default", 466 Running: true, 467 }}, 468 } 469 470 s.config.StartUniterFunc = func(runner *worker.Runner, params *uniter.UniterParams) error { 471 go func() { 472 close(uniterStarted) 473 c.Assert(params.UnitTag.Id(), gc.Equals, "gitlab/0") 474 c.Assert(params.NewRemoteRunnerExecutor, gc.NotNil) 475 select { 476 case <-params.ContainerRunningStatusChannel: 477 case <-time.After(coretesting.LongWait): 478 c.Fatal("timed out sending application change") 479 } 480 running, err := params.ContainerRunningStatusFunc("gitlab-ffff") 481 c.Assert(err, gc.IsNil) 482 c.Assert(running, jc.DeepEquals, &remotestate.ContainerRunningStatus{ 483 PodName: "gitlab-ffff", 484 Running: true, 485 }) 486 close(uniterGotRunning) 487 }() 488 return nil 489 } 490 491 w, err := caasoperator.NewWorker(s.config) 492 c.Assert(err, jc.ErrorIsNil) 493 defer workertest.CleanKill(c, w) 494 495 select { 496 case s.appChanges <- struct{}{}: 497 case <-time.After(coretesting.LongWait): 498 c.Fatal("timed out sending application change") 499 } 500 select { 501 case s.unitsChanges <- []string{"gitlab/0"}: 502 case <-time.After(coretesting.LongWait): 503 c.Fatal("timed out sending unit change") 504 } 505 select { 506 case <-s.appWatched: 507 case <-time.After(coretesting.LongWait): 508 c.Fatal("timed out waiting for application to be watched") 509 } 510 select { 511 case <-uniterStarted: 512 case <-time.After(coretesting.LongWait): 513 c.Fatalf("timeout while waiting for uniter to start") 514 } 515 select { 516 case s.containerChanges <- []string{"gitlab/0"}: 517 case <-time.After(coretesting.LongWait): 518 c.Fatalf("timeout while waiting for uniter to start") 519 } 520 select { 521 case <-uniterGotRunning: 522 case <-time.After(coretesting.LongWait): 523 c.Fatalf("timeout while waiting for uniter to receive running status") 524 } 525 526 s.client.CheckCallNames(c, "Charm", "SetStatus", "SetVersion", "WatchUnits", "WatchContainerStart", "SetStatus", "Watch", "Charm", "Life") 527 s.client.CheckCall(c, 0, "Charm", "gitlab") 528 s.client.CheckCall(c, 2, "SetVersion", "gitlab", coretesting.CurrentVersion()) 529 s.client.CheckCall(c, 3, "WatchUnits", "gitlab") 530 s.client.CheckCall(c, 4, "WatchContainerStart", "gitlab", "(?:juju-pod-init|)") 531 s.client.CheckCall(c, 6, "Watch", "gitlab") 532 } 533 534 func (s *WorkerSuite) TestOperatorNoWaitContainerStart(c *gc.C) { 535 uniterStarted := make(chan struct{}) 536 s.config.StartUniterFunc = func(runner *worker.Runner, params *uniter.UniterParams) error { 537 go func() { 538 close(uniterStarted) 539 c.Assert(params.UnitTag.Id(), gc.Equals, "gitlab/0") 540 c.Assert(params.ContainerRunningStatusChannel, gc.IsNil) 541 }() 542 return nil 543 } 544 s.client.mode = caas.ModeOperator 545 546 w, err := caasoperator.NewWorker(s.config) 547 c.Assert(err, jc.ErrorIsNil) 548 defer workertest.CleanKill(c, w) 549 550 select { 551 case s.appChanges <- struct{}{}: 552 case <-time.After(coretesting.LongWait): 553 c.Fatal("timed out sending application change") 554 } 555 select { 556 case s.unitsChanges <- []string{"gitlab/0"}: 557 case <-time.After(coretesting.LongWait): 558 c.Fatal("timed out sending unit change") 559 } 560 select { 561 case <-s.appWatched: 562 case <-time.After(coretesting.LongWait): 563 c.Fatal("timed out waiting for application to be watched") 564 } 565 select { 566 case <-uniterStarted: 567 case <-time.After(coretesting.LongWait): 568 c.Fatalf("timeout while waiting for uniter to start") 569 } 570 571 s.client.CheckCallNames(c, "Charm", "SetStatus", "SetVersion", "WatchUnits", "SetStatus", "Watch", "Charm", "Life") 572 s.client.CheckCall(c, 0, "Charm", "gitlab") 573 s.client.CheckCall(c, 2, "SetVersion", "gitlab", coretesting.CurrentVersion()) 574 s.client.CheckCall(c, 3, "WatchUnits", "gitlab") 575 s.client.CheckCall(c, 5, "Watch", "gitlab") 576 }