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  }