zotregistry.dev/zot@v1.4.4-0.20240314164342-eec277e14d20/pkg/extensions/monitoring/monitoring_test.go (about) 1 //go:build metrics 2 // +build metrics 3 4 package monitoring_test 5 6 import ( 7 "fmt" 8 "io" 9 "math/rand" 10 "net/http" 11 "os" 12 "path" 13 "testing" 14 "time" 15 16 . "github.com/smartystreets/goconvey/convey" 17 "gopkg.in/resty.v1" 18 19 "zotregistry.dev/zot/pkg/api" 20 "zotregistry.dev/zot/pkg/api/config" 21 extconf "zotregistry.dev/zot/pkg/extensions/config" 22 "zotregistry.dev/zot/pkg/extensions/monitoring" 23 "zotregistry.dev/zot/pkg/scheduler" 24 common "zotregistry.dev/zot/pkg/storage/common" 25 test "zotregistry.dev/zot/pkg/test/common" 26 . "zotregistry.dev/zot/pkg/test/image-utils" 27 ociutils "zotregistry.dev/zot/pkg/test/oci-utils" 28 ) 29 30 func TestExtensionMetrics(t *testing.T) { 31 Convey("Make a new controller with explicitly enabled metrics", t, func() { 32 port := test.GetFreePort() 33 baseURL := test.GetBaseURL(port) 34 conf := config.New() 35 conf.HTTP.Port = port 36 37 rootDir := t.TempDir() 38 39 conf.Storage.RootDirectory = rootDir 40 conf.Extensions = &extconf.ExtensionConfig{} 41 enabled := true 42 conf.Extensions.Metrics = &extconf.MetricsConfig{ 43 BaseConfig: extconf.BaseConfig{Enable: &enabled}, 44 Prometheus: &extconf.PrometheusConfig{Path: "/metrics"}, 45 } 46 47 ctlr := api.NewController(conf) 48 So(ctlr, ShouldNotBeNil) 49 50 cm := test.NewControllerManager(ctlr) 51 cm.StartAndWait(port) 52 defer cm.StopServer() 53 54 // improve code coverage 55 ctlr.Metrics.SendMetric(baseURL) 56 ctlr.Metrics.ForceSendMetric(baseURL) 57 58 So(ctlr.Metrics.IsEnabled(), ShouldBeTrue) 59 So(ctlr.Metrics.ReceiveMetrics(), ShouldBeNil) 60 61 monitoring.ObserveHTTPRepoLatency(ctlr.Metrics, 62 "/v2/alpine/blobs/uploads/299148f0-0e32-4830-90d2-a3fa744137d9", time.Millisecond) 63 monitoring.IncDownloadCounter(ctlr.Metrics, "alpine") 64 monitoring.IncUploadCounter(ctlr.Metrics, "alpine") 65 66 srcStorageCtlr := ociutils.GetDefaultStoreController(rootDir, ctlr.Log) 67 err := WriteImageToFileSystem(CreateDefaultImage(), "alpine", "0.0.1", srcStorageCtlr) 68 So(err, ShouldBeNil) 69 70 monitoring.SetStorageUsage(ctlr.Metrics, rootDir, "alpine") 71 72 monitoring.ObserveStorageLockLatency(ctlr.Metrics, time.Millisecond, rootDir, "RWLock") 73 74 resp, err := resty.R().Get(baseURL + "/metrics") 75 So(err, ShouldBeNil) 76 So(resp, ShouldNotBeNil) 77 So(resp.StatusCode(), ShouldEqual, http.StatusOK) 78 79 respStr := string(resp.Body()) 80 So(respStr, ShouldContainSubstring, "zot_repo_downloads_total{repo=\"alpine\"} 1") 81 So(respStr, ShouldContainSubstring, "zot_repo_uploads_total{repo=\"alpine\"} 1") 82 So(respStr, ShouldContainSubstring, "zot_repo_storage_bytes{repo=\"alpine\"}") 83 So(respStr, ShouldContainSubstring, "zot_storage_lock_latency_seconds_bucket") 84 So(respStr, ShouldContainSubstring, "zot_storage_lock_latency_seconds_sum") 85 So(respStr, ShouldContainSubstring, "zot_storage_lock_latency_seconds_bucket") 86 }) 87 Convey("Make a new controller with disabled metrics extension", t, func() { 88 port := test.GetFreePort() 89 baseURL := test.GetBaseURL(port) 90 conf := config.New() 91 conf.HTTP.Port = port 92 93 conf.Storage.RootDirectory = t.TempDir() 94 conf.Extensions = &extconf.ExtensionConfig{} 95 var disabled bool 96 conf.Extensions.Metrics = &extconf.MetricsConfig{BaseConfig: extconf.BaseConfig{Enable: &disabled}} 97 98 ctlr := api.NewController(conf) 99 So(ctlr, ShouldNotBeNil) 100 101 cm := test.NewControllerManager(ctlr) 102 cm.StartAndWait(port) 103 defer cm.StopServer() 104 105 So(ctlr.Metrics.IsEnabled(), ShouldBeFalse) 106 107 resp, err := resty.R().Get(baseURL + "/metrics") 108 So(err, ShouldBeNil) 109 So(resp, ShouldNotBeNil) 110 So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) 111 }) 112 } 113 114 func TestMetricsAuthentication(t *testing.T) { 115 Convey("test metrics without authentication and metrics enabled", t, func() { 116 port := test.GetFreePort() 117 baseURL := test.GetBaseURL(port) 118 conf := config.New() 119 conf.HTTP.Port = port 120 121 ctlr := api.NewController(conf) 122 ctlr.Config.Storage.RootDirectory = t.TempDir() 123 124 cm := test.NewControllerManager(ctlr) 125 cm.StartAndWait(port) 126 defer cm.StopServer() 127 128 // metrics endpoint not available 129 resp, err := resty.R().Get(baseURL + "/metrics") 130 So(err, ShouldBeNil) 131 So(resp, ShouldNotBeNil) 132 So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) 133 }) 134 Convey("test metrics without authentication and with metrics enabled", t, func() { 135 port := test.GetFreePort() 136 baseURL := test.GetBaseURL(port) 137 conf := config.New() 138 conf.HTTP.Port = port 139 enabled := true 140 metricsConfig := &extconf.MetricsConfig{ 141 BaseConfig: extconf.BaseConfig{Enable: &enabled}, 142 Prometheus: &extconf.PrometheusConfig{Path: "/metrics"}, 143 } 144 conf.Extensions = &extconf.ExtensionConfig{ 145 Metrics: metricsConfig, 146 } 147 148 ctlr := api.NewController(conf) 149 ctlr.Config.Storage.RootDirectory = t.TempDir() 150 151 cm := test.NewControllerManager(ctlr) 152 cm.StartAndWait(port) 153 defer cm.StopServer() 154 155 // without auth set metrics endpoint is available 156 resp, err := resty.R().Get(baseURL + "/metrics") 157 So(err, ShouldBeNil) 158 So(resp, ShouldNotBeNil) 159 So(resp.StatusCode(), ShouldEqual, http.StatusOK) 160 }) 161 Convey("test metrics with authentication and metrics enabled", t, func() { 162 port := test.GetFreePort() 163 baseURL := test.GetBaseURL(port) 164 conf := config.New() 165 conf.HTTP.Port = port 166 167 username := generateRandomString() 168 password := generateRandomString() 169 metricsuser := generateRandomString() 170 metricspass := generateRandomString() 171 content := test.GetCredString(username, password) + "\n" + test.GetCredString(metricsuser, metricspass) 172 htpasswdPath := test.MakeHtpasswdFileFromString(content) 173 defer os.Remove(htpasswdPath) 174 175 conf.HTTP.Auth = &config.AuthConfig{ 176 HTPasswd: config.AuthHTPasswd{ 177 Path: htpasswdPath, 178 }, 179 } 180 181 enabled := true 182 metricsConfig := &extconf.MetricsConfig{ 183 BaseConfig: extconf.BaseConfig{Enable: &enabled}, 184 Prometheus: &extconf.PrometheusConfig{Path: "/metrics"}, 185 } 186 conf.Extensions = &extconf.ExtensionConfig{ 187 Metrics: metricsConfig, 188 } 189 190 ctlr := api.NewController(conf) 191 ctlr.Config.Storage.RootDirectory = t.TempDir() 192 193 cm := test.NewControllerManager(ctlr) 194 cm.StartAndWait(port) 195 defer cm.StopServer() 196 197 // without credentials 198 resp, err := resty.R().Get(baseURL + "/metrics") 199 So(err, ShouldBeNil) 200 So(resp, ShouldNotBeNil) 201 So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) 202 203 // with wrong credentials 204 resp, err = resty.R().SetBasicAuth("atacker", "wrongpassword").Get(baseURL + "/metrics") 205 So(err, ShouldBeNil) 206 So(resp, ShouldNotBeNil) 207 So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) 208 209 // authenticated users 210 resp, err = resty.R().SetBasicAuth(username, password).Get(baseURL + "/metrics") 211 So(err, ShouldBeNil) 212 So(resp, ShouldNotBeNil) 213 So(resp.StatusCode(), ShouldEqual, http.StatusOK) 214 215 resp, err = resty.R().SetBasicAuth(metricsuser, metricspass).Get(baseURL + "/metrics") 216 So(err, ShouldBeNil) 217 So(resp, ShouldNotBeNil) 218 So(resp.StatusCode(), ShouldEqual, http.StatusOK) 219 }) 220 } 221 222 func TestMetricsAuthorization(t *testing.T) { 223 const AuthorizationAllRepos = "**" 224 225 Convey("Make a new controller with auth & metrics enabled", t, func() { 226 port := test.GetFreePort() 227 baseURL := test.GetBaseURL(port) 228 conf := config.New() 229 conf.HTTP.Port = port 230 231 username := generateRandomString() 232 password := generateRandomString() 233 metricsuser := generateRandomString() 234 metricspass := generateRandomString() 235 content := test.GetCredString(username, password) + "\n" + test.GetCredString(metricsuser, metricspass) 236 htpasswdPath := test.MakeHtpasswdFileFromString(content) 237 defer os.Remove(htpasswdPath) 238 239 conf.HTTP.Auth = &config.AuthConfig{ 240 HTPasswd: config.AuthHTPasswd{ 241 Path: htpasswdPath, 242 }, 243 } 244 245 enabled := true 246 metricsConfig := &extconf.MetricsConfig{ 247 BaseConfig: extconf.BaseConfig{Enable: &enabled}, 248 Prometheus: &extconf.PrometheusConfig{Path: "/metrics"}, 249 } 250 conf.Extensions = &extconf.ExtensionConfig{ 251 Metrics: metricsConfig, 252 } 253 254 Convey("with basic auth: no metrics users in accessControl", func() { 255 conf.HTTP.AccessControl = &config.AccessControlConfig{ 256 Metrics: config.Metrics{ 257 Users: []string{}, 258 }, 259 } 260 ctlr := api.NewController(conf) 261 ctlr.Config.Storage.RootDirectory = t.TempDir() 262 263 cm := test.NewControllerManager(ctlr) 264 cm.StartAndWait(port) 265 defer cm.StopServer() 266 267 // authenticated but not authorized user should not have access to/metrics 268 client := resty.New() 269 client.SetBasicAuth(username, password) 270 resp, err := client.R().Get(baseURL + "/metrics") 271 So(err, ShouldBeNil) 272 So(resp, ShouldNotBeNil) 273 So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) 274 275 // authenticated but not authorized user should not have access to/metrics 276 client.SetBasicAuth(metricsuser, metricspass) 277 resp, err = client.R().Get(baseURL + "/metrics") 278 So(err, ShouldBeNil) 279 So(resp, ShouldNotBeNil) 280 So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) 281 }) 282 Convey("with basic auth: metrics users in accessControl", func() { 283 conf.HTTP.AccessControl = &config.AccessControlConfig{ 284 Metrics: config.Metrics{ 285 Users: []string{metricsuser}, 286 }, 287 } 288 ctlr := api.NewController(conf) 289 ctlr.Config.Storage.RootDirectory = t.TempDir() 290 291 cm := test.NewControllerManager(ctlr) 292 cm.StartAndWait(port) 293 defer cm.StopServer() 294 295 // authenticated but not authorized user should not have access to/metrics 296 client := resty.New() 297 client.SetBasicAuth(username, password) 298 resp, err := client.R().Get(baseURL + "/metrics") 299 So(err, ShouldBeNil) 300 So(resp, ShouldNotBeNil) 301 So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) 302 303 // authenticated & authorized user should have access to/metrics 304 client.SetBasicAuth(metricsuser, metricspass) 305 resp, err = client.R().Get(baseURL + "/metrics") 306 So(err, ShouldBeNil) 307 So(resp, ShouldNotBeNil) 308 So(resp.StatusCode(), ShouldEqual, http.StatusOK) 309 }) 310 Convey("with basic auth: with anonymousPolicy in accessControl", func() { 311 conf.HTTP.AccessControl = &config.AccessControlConfig{ 312 Metrics: config.Metrics{ 313 Users: []string{metricsuser}, 314 }, 315 Repositories: config.Repositories{ 316 AuthorizationAllRepos: config.PolicyGroup{ 317 Policies: []config.Policy{ 318 { 319 Users: []string{}, 320 Actions: []string{}, 321 }, 322 }, 323 AnonymousPolicy: []string{"read"}, 324 DefaultPolicy: []string{}, 325 }, 326 }, 327 } 328 ctlr := api.NewController(conf) 329 ctlr.Config.Storage.RootDirectory = t.TempDir() 330 331 cm := test.NewControllerManager(ctlr) 332 cm.StartAndWait(port) 333 defer cm.StopServer() 334 335 // unauthenticated clients should not have access to /metrics 336 resp, err := resty.R().Get(baseURL + "/metrics") 337 So(err, ShouldBeNil) 338 So(resp, ShouldNotBeNil) 339 So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) 340 341 // unauthenticated clients should not have access to /metrics 342 resp, err = resty.R().SetBasicAuth("hacker", "trywithwrongpass").Get(baseURL + "/metrics") 343 So(err, ShouldBeNil) 344 So(resp, ShouldNotBeNil) 345 So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) 346 347 // authenticated but not authorized user should not have access to/metrics 348 client := resty.New() 349 client.SetBasicAuth(username, password) 350 resp, err = client.R().Get(baseURL + "/metrics") 351 So(err, ShouldBeNil) 352 So(resp, ShouldNotBeNil) 353 So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) 354 355 // authenticated & authorized user should have access to/metrics 356 client.SetBasicAuth(metricsuser, metricspass) 357 resp, err = client.R().Get(baseURL + "/metrics") 358 So(err, ShouldBeNil) 359 So(resp, ShouldNotBeNil) 360 So(resp.StatusCode(), ShouldEqual, http.StatusOK) 361 }) 362 Convey("with basic auth: with adminPolicy in accessControl", func() { 363 conf.HTTP.AccessControl = &config.AccessControlConfig{ 364 Metrics: config.Metrics{ 365 Users: []string{metricsuser}, 366 }, 367 Repositories: config.Repositories{ 368 AuthorizationAllRepos: config.PolicyGroup{ 369 Policies: []config.Policy{ 370 { 371 Users: []string{}, 372 Actions: []string{}, 373 }, 374 }, 375 DefaultPolicy: []string{}, 376 }, 377 }, 378 AdminPolicy: config.Policy{ 379 Users: []string{"test"}, 380 Groups: []string{"admins"}, 381 Actions: []string{"read", "create", "update", "delete"}, 382 }, 383 } 384 ctlr := api.NewController(conf) 385 ctlr.Config.Storage.RootDirectory = t.TempDir() 386 387 cm := test.NewControllerManager(ctlr) 388 cm.StartAndWait(port) 389 defer cm.StopServer() 390 391 // unauthenticated clients should not have access to /metrics 392 resp, err := resty.R().Get(baseURL + "/metrics") 393 So(err, ShouldBeNil) 394 So(resp, ShouldNotBeNil) 395 So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) 396 397 // unauthenticated clients should not have access to /metrics 398 resp, err = resty.R().SetBasicAuth("hacker", "trywithwrongpass").Get(baseURL + "/metrics") 399 So(err, ShouldBeNil) 400 So(resp, ShouldNotBeNil) 401 So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) 402 403 // authenticated admin user (but not authorized) should not have access to/metrics 404 client := resty.New() 405 client.SetBasicAuth(username, password) 406 resp, err = client.R().Get(baseURL + "/metrics") 407 So(err, ShouldBeNil) 408 So(resp, ShouldNotBeNil) 409 So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) 410 411 // authenticated & authorized user should have access to/metrics 412 client.SetBasicAuth(metricsuser, metricspass) 413 resp, err = client.R().Get(baseURL + "/metrics") 414 So(err, ShouldBeNil) 415 So(resp, ShouldNotBeNil) 416 So(resp.StatusCode(), ShouldEqual, http.StatusOK) 417 }) 418 }) 419 } 420 421 func TestPopulateStorageMetrics(t *testing.T) { 422 Convey("Start a scheduler when metrics enabled", t, func() { 423 port := test.GetFreePort() 424 baseURL := test.GetBaseURL(port) 425 conf := config.New() 426 conf.HTTP.Port = port 427 428 rootDir := t.TempDir() 429 430 conf.Storage.RootDirectory = rootDir 431 conf.Extensions = &extconf.ExtensionConfig{} 432 enabled := true 433 conf.Extensions.Metrics = &extconf.MetricsConfig{ 434 BaseConfig: extconf.BaseConfig{Enable: &enabled}, 435 Prometheus: &extconf.PrometheusConfig{Path: "/metrics"}, 436 } 437 438 logFile, err := os.CreateTemp(t.TempDir(), "zot-log*.txt") 439 if err != nil { 440 panic(err) 441 } 442 443 logPath := logFile.Name() 444 defer os.Remove(logPath) 445 446 writers := io.MultiWriter(os.Stdout, logFile) 447 448 ctlr := api.NewController(conf) 449 So(ctlr, ShouldNotBeNil) 450 ctlr.Log.Logger = ctlr.Log.Output(writers) 451 452 cm := test.NewControllerManager(ctlr) 453 cm.StartAndWait(port) 454 defer cm.StopServer() 455 456 // write a couple of images 457 srcStorageCtlr := ociutils.GetDefaultStoreController(rootDir, ctlr.Log) 458 err = WriteImageToFileSystem(CreateDefaultImage(), "alpine", "0.0.1", srcStorageCtlr) 459 So(err, ShouldBeNil) 460 err = WriteImageToFileSystem(CreateDefaultImage(), "busybox", "0.0.1", srcStorageCtlr) 461 So(err, ShouldBeNil) 462 463 metrics := monitoring.NewMetricsServer(true, ctlr.Log) 464 sch := scheduler.NewScheduler(conf, metrics, ctlr.Log) 465 sch.RunScheduler() 466 467 generator := &common.StorageMetricsInitGenerator{ 468 ImgStore: ctlr.StoreController.DefaultStore, 469 Metrics: ctlr.Metrics, 470 Log: ctlr.Log, 471 MaxDelay: 1, // maximum delay between jobs (each job computes repo's storage size) 472 } 473 474 sch.SubmitGenerator(generator, time.Duration(0), scheduler.LowPriority) 475 476 // Wait for storage metrics to update 477 found, err := test.ReadLogFileAndSearchString(logPath, 478 "computed storage usage for repo alpine", time.Minute) 479 So(err, ShouldBeNil) 480 So(found, ShouldBeTrue) 481 found, err = test.ReadLogFileAndSearchString(logPath, 482 "computed storage usage for repo busybox", time.Minute) 483 So(err, ShouldBeNil) 484 So(found, ShouldBeTrue) 485 486 sch.Shutdown() 487 alpineSize, err := monitoring.GetDirSize(path.Join(rootDir, "alpine")) 488 So(err, ShouldBeNil) 489 busyboxSize, err := monitoring.GetDirSize(path.Join(rootDir, "busybox")) 490 So(err, ShouldBeNil) 491 492 resp, err := resty.R().Get(baseURL + "/metrics") 493 So(err, ShouldBeNil) 494 So(resp, ShouldNotBeNil) 495 So(resp.StatusCode(), ShouldEqual, http.StatusOK) 496 497 alpineMetric := fmt.Sprintf("zot_repo_storage_bytes{repo=\"alpine\"} %d", alpineSize) 498 busyboxMetric := fmt.Sprintf("zot_repo_storage_bytes{repo=\"busybox\"} %d", busyboxSize) 499 respStr := string(resp.Body()) 500 So(respStr, ShouldContainSubstring, alpineMetric) 501 So(respStr, ShouldContainSubstring, busyboxMetric) 502 }) 503 } 504 505 func generateRandomString() string { 506 //nolint: gosec 507 seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) 508 charset := "abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" 509 510 randomBytes := make([]byte, 10) 511 for i := range randomBytes { 512 randomBytes[i] = charset[seededRand.Intn(len(charset))] 513 } 514 515 return string(randomBytes) 516 }