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  }