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