Repository: G-Research/fasttrackml
Pull request #232 (2023-08-10 15:01:33)

Add support for basic artifact logging - local storage
PR introduced `local` storage for `artifact` logging.


Patch:
diff --git a/go.mod b/go.mod
index 38b6c31..6964be0 100644
--- a/go.mod
+++ b/go.mod
@@ -27,6 +27,7 @@ require (
 	github.com/spf13/cobra v1.7.0
 	github.com/spf13/viper v1.16.0
 	github.com/stretchr/testify v1.8.4
+	golang.org/x/exp v0.0.0-20220827204233-334a2380cb91
 	gorm.io/driver/postgres v1.5.2
 	gorm.io/driver/sqlite v1.4.3
 	gorm.io/gorm v1.25.2
diff --git a/go.sum b/go.sum
index 5e06f16..dda1097 100644
--- a/go.sum
+++ b/go.sum
@@ -329,6 +329,7 @@ golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u0
 golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
 golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
 golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 h1:tnebWN09GYg9OLPss1KXj8txwZc6X6uMr6VFdcGNbHw=
+golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
 golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
 golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
diff --git a/pkg/api/aim/query/query.go b/pkg/api/aim/query/query.go
index 00677c8..6799fa4 100644
--- a/pkg/api/aim/query/query.go
+++ b/pkg/api/aim/query/query.go
@@ -409,7 +409,7 @@ func (pq *parsedQuery) parseName(node *ast.Name) (any, error) {
 		case "run":
 			table, ok := pq.qp.Tables["runs"]
 			if !ok {
-				return nil, errors.New(`unsupported name identifier "run"`)
+				return nil, errors.New("unsupported name identifier 'run'")
 			}
 			return attributeGetter(
 				func(attr string) (any, error) {
@@ -437,7 +437,7 @@ func (pq *parsedQuery) parseName(node *ast.Name) (any, error) {
 					case "experiment":
 						e, ok := pq.qp.Tables["experiments"]
 						if !ok {
-							return nil, errors.New(`unsupported attribute "experiment"`)
+							return nil, errors.New("unsupported attribute 'experiment'")
 						}
 						return clause.Column{
 							Table: e,
@@ -560,7 +560,7 @@ func (pq *parsedQuery) parseName(node *ast.Name) (any, error) {
 		case "metric":
 			table, ok := pq.qp.Tables["metrics"]
 			if !ok {
-				return nil, errors.New(`unsupported name identifier "metric"`)
+				return nil, errors.New("unsupported name identifier 'metric'")
 			}
 			return attributeGetter(
 				func(attr string) (any, error) {
diff --git a/pkg/api/mlflow/api/response/experiment.go b/pkg/api/mlflow/api/response/experiment.go
index 94c1f6b..7bd9a04 100644
--- a/pkg/api/mlflow/api/response/experiment.go
+++ b/pkg/api/mlflow/api/response/experiment.go
@@ -72,7 +72,7 @@ func NewSearchExperimentsResponse(
 		).Encode(request.PageToken{
 			Offset: int32(offset + limit),
 		}); err != nil {
-			return nil, eris.Wrap(err, "error encoding `nextPageToken` value")
+			return nil, eris.Wrap(err, "error encoding 'nextPageToken' value")
 		}
 	}
 
diff --git a/pkg/api/mlflow/api/response/run.go b/pkg/api/mlflow/api/response/run.go
index d45c0b9..7b136cf 100644
--- a/pkg/api/mlflow/api/response/run.go
+++ b/pkg/api/mlflow/api/response/run.go
@@ -154,7 +154,7 @@ func NewSearchRunsResponse(runs []models.Run, limit, offset int) (*SearchRunsRes
 		).Encode(request.PageToken{
 			Offset: int32(offset + limit),
 		}); err != nil {
-			return nil, eris.Wrap(err, "error encoding `nextPageToken` value")
+			return nil, eris.Wrap(err, "error encoding 'nextPageToken' value")
 		}
 		resp.NextPageToken = token.String()
 	}
diff --git a/pkg/api/mlflow/config/config.go b/pkg/api/mlflow/config/config.go
index b94522a..d065973 100644
--- a/pkg/api/mlflow/config/config.go
+++ b/pkg/api/mlflow/config/config.go
@@ -2,10 +2,13 @@ package config
 
 import (
 	"net/url"
+	"path"
+	"path/filepath"
 	"time"
 
-	"github.com/rotisserie/eris"
+	"golang.org/x/exp/slices"
 
+	"github.com/rotisserie/eris"
 	"github.com/spf13/viper"
 )
 
@@ -42,25 +45,50 @@ func NewServiceConfig() *ServiceConfig {
 }
 
 // Validate validates service configuration.
-func (c ServiceConfig) Validate() error {
-	if err := c.validateArtifactRoot(); err != nil {
+func (c *ServiceConfig) Validate() error {
+	if err := c.validateConfiguration(); err != nil {
 		return eris.Wrap(err, "error validating service configuration")
 	}
+	if err := c.normalizeConfiguration(); err != nil {
+		return eris.Wrap(err, "error normalizing service configuration")
+	}
+	return nil
+}
+
+// validateConfiguration validates service configuration for correctness.
+func (c *ServiceConfig) validateConfiguration() error {
+	// 1. validate ArtifactRoot configuration parameter for correctness and valid values.
+	parsed, err := url.Parse(c.ArtifactRoot)
+	if err != nil {
+		return eris.Wrap(err, "error parsing 'artifact-root' flag")
+	}
+
+	if parsed.User != nil || parsed.RawQuery != "" || parsed.RawFragment != "" {
+		return eris.New("incorrect format of 'artifact-root' flag")
+	}
+
+	if !slices.Contains([]string{"", "file", "s3"}, parsed.Scheme) {
+		return eris.New("unsupported schema of 'artifact-root' flag")
+	}
+
 	return nil
 }
 
-// validateArtifactRoot validates `artifact-root` configuration parameter.
-// for s3 storage it has to be: s3://bucket_name.
-func (c ServiceConfig) validateArtifactRoot() error {
+// normalizeConfiguration normalizes service configuration parameters.
+func (c *ServiceConfig) normalizeConfiguration() error {
 	parsed, err := url.Parse(c.ArtifactRoot)
 	if err != nil {
-		return eris.Wrap(err, "error parsing `artifact-root` flag")
+		return eris.Wrap(err, "error parsing 'artifact-root' flag")
 	}
 	switch parsed.Scheme {
 	case "s3":
-		if parsed.User != nil || parsed.RawQuery != "" || parsed.RawFragment != "" {
-			return eris.New("incorrect format of `artifact-root` flag. has to be s3://bucket_name")
+		return nil
+	case "", "file":
+		absoluteArtifactRoot, err := filepath.Abs(path.Join(parsed.Host, parsed.Path))
+		if err != nil {
+			return eris.Wrapf(err, "error getting absolute path for 'artifact-root': %s", c.ArtifactRoot)
 		}
+		c.ArtifactRoot = absoluteArtifactRoot
 	}
 	return nil
 }
diff --git a/pkg/api/mlflow/dao/repositories/run.go b/pkg/api/mlflow/dao/repositories/run.go
index 9a6b2b0..a7e8811 100644
--- a/pkg/api/mlflow/dao/repositories/run.go
+++ b/pkg/api/mlflow/dao/repositories/run.go
@@ -71,7 +71,7 @@ func (r RunRepository) GetByID(ctx context.Context, id string) (*models.Run, err
 	).Preload(
 		"Tags",
 	).First(&run).Error; err != nil {
-		return nil, eris.Wrapf(err, "error getting `run` entity by id: %s", id)
+		return nil, eris.Wrapf(err, "error getting 'run' entity by id: %s", id)
 	}
 	return &run, nil
 }
@@ -95,7 +95,7 @@ func (r RunRepository) GetByIDAndLifecycleStage(
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 			return nil, nil
 		}
-		return nil, eris.Wrapf(err, "error getting `run` entity by id: %s", id)
+		return nil, eris.Wrapf(err, "error getting 'run' entity by id: %s", id)
 	}
 	return &run, nil
 }
@@ -111,7 +111,7 @@ func (r RunRepository) Create(ctx context.Context, run *models.Run) error {
 		}
 		return tx.Create(&run).Error
 	}); err != nil {
-		return eris.Wrap(err, "error creating new `run` entity")
+		return eris.Wrap(err, "error creating new 'run' entity")
 	}
 	return nil
 }
@@ -230,12 +230,12 @@ func (r RunRepository) SetRunTagsBatch(ctx context.Context, run *models.Run, bat
 			case "mlflow.user":
 				run.UserID = tag.Value
 				if err := r.UpdateWithTransaction(ctx, tx, run); err != nil {
-					return eris.Wrap(err, "error updating run `user_id` field")
+					return eris.Wrap(err, "error updating run 'user_id' field")
 				}
 			case "mlflow.runName":
 				run.Name = tag.Value
 				if err := r.UpdateWithTransaction(ctx, tx, run); err != nil {
-					return eris.Wrap(err, "error updating run `name` field")
+					return eris.Wrap(err, "error updating run 'name' field")
 				}
 			}
 		}
diff --git a/pkg/api/mlflow/service/artifact/storage/local.go b/pkg/api/mlflow/service/artifact/storage/local.go
new file mode 100644
index 0000000..b2a0283
--- /dev/null
+++ b/pkg/api/mlflow/service/artifact/storage/local.go
@@ -0,0 +1,54 @@
+package storage
+
+import (
+	"net/url"
+	"os"
+	"path/filepath"
+
+	"github.com/rotisserie/eris"
+	log "github.com/sirupsen/logrus"
+
+	"github.com/G-Research/fasttrackml/pkg/api/mlflow/config"
+)
+
+// Local represents local file storage adapter to work with artifacts.
+type Local struct {
+	config *config.ServiceConfig
+}
+
+// NewLocal creates new Local storage instance.
+func NewLocal(config *config.ServiceConfig) (*Local, error) {
+	return &Local{
+		config: config,
+	}, nil
+}
+
+// List implements Provider interface.
+func (s Local) List(artifactURI, path, _ string) (string, string, []ArtifactObject, error) {
+	// 1. process search `prefix` parameter.
+	path, err := url.JoinPath(artifactURI, path)
+	if err != nil {
+		return "", "", nil, eris.Wrap(err, "error constructing full path")
+	}
+
+	// 2. read data from local storage.
+	objects, err := os.ReadDir(path)
+	if err != nil {
+		return "", "", nil, eris.Wrapf(err, "error reading object from local storage")
+	}
+
+	log.Debugf("got %d objects from local storage for path: %s", len(objects), path)
+	artifactList := make([]ArtifactObject, len(objects))
+	for i, object := range objects {
+		info, err := object.Info()
+		if err != nil {
+			return "", "", nil, eris.Wrapf(err, "error getting info for object: %s", object.Name())
+		}
+		artifactList[i] = ArtifactObject{
+			Path:  filepath.Join(path, info.Name()),
+			Size:  info.Size(),
+			IsDir: object.IsDir(),
+		}
+	}
+	return "", s.config.ArtifactRoot, artifactList, nil
+}
diff --git a/pkg/api/mlflow/service/artifact/storage/storage.go b/pkg/api/mlflow/service/artifact/storage/storage.go
index af91983..5c6b3c0 100644
--- a/pkg/api/mlflow/service/artifact/storage/storage.go
+++ b/pkg/api/mlflow/service/artifact/storage/storage.go
@@ -46,6 +46,10 @@ func NewArtifactStorage(config *config.ServiceConfig) (Provider, error) {
 		switch u.Scheme {
 		case "s3":
 			return NewS3(config)
+		case "", "file":
+			return NewLocal(config)
+		default:
+			return nil, eris.Errorf("unsupported schema has been provided: %s", u.Scheme)
 		}
 	}
 	return NewNoop(), nil
diff --git a/pkg/api/mlflow/service/artifact/validators.go b/pkg/api/mlflow/service/artifact/validators.go
index dc57bc4..a9f25fe 100644
--- a/pkg/api/mlflow/service/artifact/validators.go
+++ b/pkg/api/mlflow/service/artifact/validators.go
@@ -2,8 +2,11 @@ package artifact
 
 import (
 	"net/url"
+	"path/filepath"
 	"strings"
 
+	"golang.org/x/exp/slices"
+
 	"github.com/G-Research/fasttrackml/pkg/api/mlflow/api"
 	"github.com/G-Research/fasttrackml/pkg/api/mlflow/api/request"
 )
@@ -20,13 +23,13 @@ func ValidateListArtifactsRequest(req *request.ListArtifactsRequest) error {
 	}
 	if parsedUrl.Scheme != "" || parsedUrl.Host != "" || parsedUrl.RawQuery != "" ||
 		parsedUrl.RawFragment != "" || parsedUrl.User != nil {
-		return api.NewInvalidParameterValueError("incorrect 'path' parameter has been provided")
+		return api.NewInvalidParameterValueError("provided 'path' parameter is invalid")
 	}
-
-	for _, path := range strings.Split(parsedUrl.Path, "/") {
-		if path == ".." {
-			return api.NewInvalidParameterValueError("provided 'path' parameter has to be absolute")
-		}
+	if filepath.IsAbs(parsedUrl.Path) {
+		return api.NewInvalidParameterValueError("provided 'path' parameter is invalid")
+	}
+	if slices.Contains(strings.Split(parsedUrl.Path, "/"), "..") {
+		return api.NewInvalidParameterValueError("provided 'path' parameter is invalid")
 	}
 	return nil
 }
diff --git a/pkg/api/mlflow/service/experiment/service.go b/pkg/api/mlflow/service/experiment/service.go
index fd25298..922eda5 100644
--- a/pkg/api/mlflow/service/experiment/service.go
+++ b/pkg/api/mlflow/service/experiment/service.go
@@ -58,7 +58,7 @@ func (s Service) CreateExperiment(
 
 	experiment, err := s.experimentRepository.GetByName(ctx, req.Name)
 	if err != nil {
-		return nil, api.NewInternalError(`error getting experiment with name: '%s', error: %s`, req.Name, err)
+		return nil, api.NewInternalError("error getting experiment with name: '%s', error: %s", req.Name, err)
 	}
 	if experiment != nil {
 		return nil, api.NewResourceAlreadyExistsError("experiment(name=%s) already exists", req.Name)
diff --git a/pkg/api/mlflow/service/metric/service.go b/pkg/api/mlflow/service/metric/service.go
index 8819e6f..e3d8d37 100644
--- a/pkg/api/mlflow/service/metric/service.go
+++ b/pkg/api/mlflow/service/metric/service.go
@@ -32,7 +32,7 @@ func (s Service) GetMetricHistory(
 	metrics, err := s.metricRepository.GetMetricHistoryByRunIDAndKey(ctx, req.GetRunID(), req.MetricKey)
 	if err != nil {
 		return nil, api.NewInternalError(
-			"unable to get metric history for metric %q of run %q", req.MetricKey, req.GetRunID(),
+			"unable to get metric history for metric '%s' of run '%s'", req.MetricKey, req.GetRunID(),
 		)
 	}
 


Test patch:
diff --git a/pkg/api/mlflow/config/config_test.go b/pkg/api/mlflow/config/config_test.go
new file mode 100644
index 0000000..8bea946
--- /dev/null
+++ b/pkg/api/mlflow/config/config_test.go
@@ -0,0 +1,109 @@
+package config
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/rotisserie/eris"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestServiceConfig_Validate_Ok(t *testing.T) {
+	testData := []struct {
+		name           string
+		providedConfig *ServiceConfig
+		expectedConfig *ServiceConfig
+	}{
+		{
+			name: "ArtifactRootHasS3Prefix",
+			providedConfig: &ServiceConfig{
+				ArtifactRoot: "s3://bucket_name",
+			},
+			expectedConfig: &ServiceConfig{
+				ArtifactRoot: "s3://bucket_name",
+			},
+		},
+		{
+			name: "ArtifactRootHasFilePrefixAndIsRelative",
+			providedConfig: &ServiceConfig{
+				ArtifactRoot: "file://path1/path2/path3",
+			},
+			expectedConfig: &ServiceConfig{
+				ArtifactRoot: (func() string {
+					path, err := filepath.Abs("path1/path2/path3")
+					assert.Nil(t, err)
+					return path
+				})(),
+			},
+		},
+		{
+			name: "ArtifactRootHasFilePrefixAndIsAbsolute",
+			providedConfig: &ServiceConfig{
+				ArtifactRoot: "file:///path1/path2/path3",
+			},
+			expectedConfig: &ServiceConfig{
+				ArtifactRoot: "/path1/path2/path3",
+			},
+		},
+		{
+			name: "ArtifactRootHasEmptyPrefixAndIsAbsolute",
+			providedConfig: &ServiceConfig{
+				ArtifactRoot: "/path1/path2/path3",
+			},
+			expectedConfig: &ServiceConfig{
+				ArtifactRoot: "/path1/path2/path3",
+			},
+		},
+		{
+			name: "ArtifactRootHasEmptyPrefixAndIsRelative",
+			providedConfig: &ServiceConfig{
+				ArtifactRoot: "path1/path2/path3",
+			},
+			expectedConfig: &ServiceConfig{
+				ArtifactRoot: (func() string {
+					path, err := filepath.Abs("path1/path2/path3")
+					assert.Nil(t, err)
+					return path
+				})(),
+			},
+		},
+	}
+
+	for _, tt := range testData {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Nil(t, tt.providedConfig.Validate())
+			assert.Equal(t, tt.providedConfig.ArtifactRoot, tt.expectedConfig.ArtifactRoot)
+		})
+	}
+}
+
+func TestServiceConfig_Validate_Error(t *testing.T) {
+	testData := []struct {
+		name   string
+		error  error
+		config *ServiceConfig
+	}{
+		{
+			name: "ArtifactRootHasIncorrectFormat",
+			error: eris.New(
+				`error validating service configuration: error parsing 'artifact-root' flag: parse "incorrect_format_of_schema://something": first path segment in URL cannot contain colon`,
+			),
+			config: &ServiceConfig{
+				ArtifactRoot: "incorrect_format_of_schema://something",
+			},
+		},
+		{
+			name:  "ArtifactRootHasUnsupportedSchema",
+			error: eris.New("error validating service configuration: unsupported schema of 'artifact-root' flag"),
+			config: &ServiceConfig{
+				ArtifactRoot: "unsupported://something",
+			},
+		},
+	}
+
+	for _, tt := range testData {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.error.Error(), tt.config.Validate().Error())
+		})
+	}
+}
diff --git a/pkg/api/mlflow/service/artifact/service_test.go b/pkg/api/mlflow/service/artifact/service_test.go
index 3320bb5..3dd091d 100644
--- a/pkg/api/mlflow/service/artifact/service_test.go
+++ b/pkg/api/mlflow/service/artifact/service_test.go
@@ -82,7 +82,7 @@ func TestService_ListArtifacts_Error(t *testing.T) {
 	}{
 		{
 			name:    "EmptyOrIncorrectRunID",
-			error:   api.NewInvalidParameterValueError(`Missing value for required parameter 'run_id'`),
+			error:   api.NewInvalidParameterValueError("Missing value for required parameter 'run_id'"),
 			request: &request.ListArtifactsRequest{},
 			service: func() *Service {
 				return NewService(
@@ -93,7 +93,7 @@ func TestService_ListArtifacts_Error(t *testing.T) {
 		},
 		{
 			name:  "PathIsRelativeAndContains2Dots",
-			error: api.NewInvalidParameterValueError("provided 'path' parameter has to be absolute"),
+			error: api.NewInvalidParameterValueError("provided 'path' parameter is invalid"),
 			request: &request.ListArtifactsRequest{
 				RunID: "id",
 				Path:  "../",
@@ -107,7 +107,7 @@ func TestService_ListArtifacts_Error(t *testing.T) {
 		},
 		{
 			name:  "RunNotFoundDatabaseError",
-			error: api.NewInternalError(`unable to get artifact URI for run 'id'`),
+			error: api.NewInternalError("unable to get artifact URI for run 'id'"),
 			request: &request.ListArtifactsRequest{
 				RunID: "id",
 			},
@@ -126,7 +126,7 @@ func TestService_ListArtifacts_Error(t *testing.T) {
 		},
 		{
 			name:  "StorageError",
-			error: api.NewInternalError(`error getting artifact list from storage`),
+			error: api.NewInternalError("error getting artifact list from storage"),
 			request: &request.ListArtifactsRequest{
 				RunID: "id",
 			},
diff --git a/pkg/api/mlflow/service/artifact/validators_test.go b/pkg/api/mlflow/service/artifact/validators_test.go
index 3081812..ca2cff4 100644
--- a/pkg/api/mlflow/service/artifact/validators_test.go
+++ b/pkg/api/mlflow/service/artifact/validators_test.go
@@ -92,7 +92,7 @@ func TestValidateListArtifactsRequest_Error(t *testing.T) {
 		},
 		{
 			name:  "IncorrectPathProvidedCase1",
-			error: api.NewInvalidParameterValueError("provided 'path' parameter has to be absolute"),
+			error: api.NewInvalidParameterValueError("provided 'path' parameter is invalid"),
 			request: &request.ListArtifactsRequest{
 				RunID: "run_id",
 				Path:  "..",
@@ -100,7 +100,7 @@ func TestValidateListArtifactsRequest_Error(t *testing.T) {
 		},
 		{
 			name:  "IncorrectPathProvidedCase2",
-			error: api.NewInvalidParameterValueError("provided 'path' parameter has to be absolute"),
+			error: api.NewInvalidParameterValueError("provided 'path' parameter is invalid"),
 			request: &request.ListArtifactsRequest{
 				RunID: "run_id",
 				Path:  "./..",
@@ -108,7 +108,7 @@ func TestValidateListArtifactsRequest_Error(t *testing.T) {
 		},
 		{
 			name:  "IncorrectPathProvidedCase3",
-			error: api.NewInvalidParameterValueError("provided 'path' parameter has to be absolute"),
+			error: api.NewInvalidParameterValueError("provided 'path' parameter is invalid"),
 			request: &request.ListArtifactsRequest{
 				RunID: "run_id",
 				Path:  "./../",
@@ -116,12 +116,20 @@ func TestValidateListArtifactsRequest_Error(t *testing.T) {
 		},
 		{
 			name:  "IncorrectPathProvidedCase4",
-			error: api.NewInvalidParameterValueError("provided 'path' parameter has to be absolute"),
+			error: api.NewInvalidParameterValueError("provided 'path' parameter is invalid"),
 			request: &request.ListArtifactsRequest{
 				RunID: "run_id",
 				Path:  "foo/../bar",
 			},
 		},
+		{
+			name:  "IncorrectPathProvidedCase5",
+			error: api.NewInvalidParameterValueError("provided 'path' parameter is invalid"),
+			request: &request.ListArtifactsRequest{
+				RunID: "run_id",
+				Path:  "/foo/../bar",
+			},
+		},
 	}
 
 	for _, tt := range testData {
diff --git a/pkg/api/mlflow/service/metric/service_test.go b/pkg/api/mlflow/service/metric/service_test.go
index a111a42..c486829 100644
--- a/pkg/api/mlflow/service/metric/service_test.go
+++ b/pkg/api/mlflow/service/metric/service_test.go
@@ -60,7 +60,7 @@ func TestService_GetMetricHistory_Error(t *testing.T) {
 	}{
 		{
 			name:    "EmptyOrIncorrectRunID",
-			error:   api.NewInvalidParameterValueError(`Missing value for required parameter 'run_id'`),
+			error:   api.NewInvalidParameterValueError("Missing value for required parameter 'run_id'"),
 			request: &request.GetMetricHistoryRequest{},
 			service: func() *Service {
 				metricRepository := repositories.MockMetricRepositoryProvider{}
@@ -69,7 +69,7 @@ func TestService_GetMetricHistory_Error(t *testing.T) {
 		},
 		{
 			name:  "EmptyOrIncorrectMetricKey",
-			error: api.NewInvalidParameterValueError(`Missing value for required parameter 'metric_key'`),
+			error: api.NewInvalidParameterValueError("Missing value for required parameter 'metric_key'"),
 			request: &request.GetMetricHistoryRequest{
 				RunID: "1",
 			},
@@ -80,7 +80,7 @@ func TestService_GetMetricHistory_Error(t *testing.T) {
 		},
 		{
 			name:  "GetMetricHistoryDatabaseError",
-			error: api.NewInternalError(`unable to get metric history for metric "key" of run "1"`),
+			error: api.NewInternalError("unable to get metric history for metric 'key' of run '1'"),
 			request: &request.GetMetricHistoryRequest{
 				RunID:     "1",
 				MetricKey: "key",
@@ -275,7 +275,7 @@ func TestNewService_GetMetricHistories_Error(t *testing.T) {
 		},
 		{
 			name:  "UnsupportedViewType",
-			error: api.NewInvalidParameterValueError(`Invalid run_view_type 'unsupported'`),
+			error: api.NewInvalidParameterValueError("Invalid run_view_type 'unsupported'"),
 			request: &request.GetMetricHistoriesRequest{
 				RunIDs:   []string{"1"},
 				ViewType: "unsupported",
diff --git a/pkg/api/mlflow/service/run/service_test.go b/pkg/api/mlflow/service/run/service_test.go
index cba85ca..7b7ba01 100644
--- a/pkg/api/mlflow/service/run/service_test.go
+++ b/pkg/api/mlflow/service/run/service_test.go
@@ -208,7 +208,7 @@ func TestService_UpdateRun_Error(t *testing.T) {
 	}{
 		{
 			name:    "EmptyOrIncorrectRunID",
-			error:   api.NewInvalidParameterValueError(`Missing value for required parameter 'run_id'`),
+			error:   api.NewInvalidParameterValueError("Missing value for required parameter 'run_id'"),
 			request: &request.UpdateRunRequest{},
 			service: func() *Service {
 				return NewService(
@@ -222,7 +222,7 @@ func TestService_UpdateRun_Error(t *testing.T) {
 		},
 		{
 			name:  "RunNotFoundOrDatabaseError",
-			error: api.NewResourceDoesNotExistError(`unable to find run '1': database error`),
+			error: api.NewResourceDoesNotExistError("unable to find run '1': database error"),
 			request: &request.UpdateRunRequest{
 				RunID: "1",
 			},
@@ -294,7 +294,7 @@ func TestService_RestoreRun_Error(t *testing.T) {
 	}{
 		{
 			name:    "EmptyOrIncorrectRunID",
-			error:   api.NewInvalidParameterValueError(`Missing value for required parameter 'run_id'`),
+			error:   api.NewInvalidParameterValueError("Missing value for required parameter 'run_id'"),
 			request: &request.RestoreRunRequest{},
 			service: func() *Service {
 				return NewService(
@@ -308,7 +308,7 @@ func TestService_RestoreRun_Error(t *testing.T) {
 		},
 		{
 			name:  "RunNotFoundDatabaseError",
-			error: api.NewResourceDoesNotExistError(`unable to find run '1': database error`),
+			error: api.NewResourceDoesNotExistError("unable to find run '1': database error"),
 			request: &request.RestoreRunRequest{
 				RunID: "1",
 			},
@@ -330,7 +330,7 @@ func TestService_RestoreRun_Error(t *testing.T) {
 		},
 		{
 			name:  "RestoreRunDatabaseError",
-			error: api.NewInternalError(`unable to restore run '1': database error`),
+			error: api.NewInternalError("unable to restore run '1': database error"),
 			request: &request.RestoreRunRequest{
 				RunID: "1",
 			},
@@ -448,7 +448,7 @@ func TestService_DeleteRun_Error(t *testing.T) {
 	}{
 		{
 			name:    "EmptyOrIncorrectRunID",
-			error:   api.NewInvalidParameterValueError(`Missing value for required parameter 'run_id'`),
+			error:   api.NewInvalidParameterValueError("Missing value for required parameter 'run_id'"),
 			request: &request.DeleteRunRequest{},
 			service: func() *Service {
 				return NewService(
@@ -462,7 +462,7 @@ func TestService_DeleteRun_Error(t *testing.T) {
 		},
 		{
 			name:  "RunNotFoundOrDatabaseError",
-			error: api.NewResourceDoesNotExistError(`unable to find run '1': database error`),
+			error: api.NewResourceDoesNotExistError("unable to find run '1': database error"),
 			request: &request.DeleteRunRequest{
 				RunID: "1",
 			},
@@ -484,7 +484,7 @@ func TestService_DeleteRun_Error(t *testing.T) {
 		},
 		{
 			name:  "RunNotFoundOrDatabaseError",
-			error: api.NewResourceDoesNotExistError(`unable to find run '1': database error`),
+			error: api.NewResourceDoesNotExistError("unable to find run '1': database error"),
 			request: &request.DeleteRunRequest{
 				RunID: "1",
 			},
@@ -506,7 +506,7 @@ func TestService_DeleteRun_Error(t *testing.T) {
 		},
 		{
 			name:  "DeleteRunDatabaseError",
-			error: api.NewInternalError(`unable to delete run '1': database error`),
+			error: api.NewInternalError("unable to delete run '1': database error"),
 			request: &request.DeleteRunRequest{
 				RunID: "1",
 			},
@@ -557,7 +557,7 @@ func TestService_DeleteRunTag_Error(t *testing.T) {
 	}{
 		{
 			name:    "EmptyOrIncorrectRunID",
-			error:   api.NewInvalidParameterValueError(`Missing value for required parameter 'run_id'`),
+			error:   api.NewInvalidParameterValueError("Missing value for required parameter 'run_id'"),
 			request: &request.DeleteRunTagRequest{},
 			service: func() *Service {
 				return NewService(
@@ -571,7 +571,7 @@ func TestService_DeleteRunTag_Error(t *testing.T) {
 		},
 		{
 			name:  "RunNotFoundOrDatabaseError",
-			error: api.NewInternalError(`Unable to find run '1': database error`),
+			error: api.NewInternalError("Unable to find run '1': database error"),
 			request: &request.DeleteRunTagRequest{
 				RunID: "1",
 			},
@@ -594,7 +594,7 @@ func TestService_DeleteRunTag_Error(t *testing.T) {
 		},
 		{
 			name:  "ActiveRunNotFound",
-			error: api.NewResourceDoesNotExistError(`Unable to find active run '1'`),
+			error: api.NewResourceDoesNotExistError("Unable to find active run '1'"),
 			request: &request.DeleteRunTagRequest{
 				RunID: "1",
 			},
@@ -617,7 +617,7 @@ func TestService_DeleteRunTag_Error(t *testing.T) {
 		},
 		{
 			name:  "NotFoundTag",
-			error: api.NewResourceDoesNotExistError(`Unable to find tag 'key' for run '1': database error`),
+			error: api.NewResourceDoesNotExistError("Unable to find tag 'key' for run '1': database error"),
 			request: &request.DeleteRunTagRequest{
 				RunID: "1",
 				Key:   "key",
@@ -651,7 +651,7 @@ func TestService_DeleteRunTag_Error(t *testing.T) {
 		},
 		{
 			name:  "DeleteRunTagDatabaseError",
-			error: api.NewInternalError(`unable to delete tag 'key' for run '1': database error`),
+			error: api.NewInternalError("unable to delete tag 'key' for run '1': database error"),
 			request: &request.DeleteRunTagRequest{
 				RunID: "1",
 				Key:   "key",
diff --git a/pkg/api/mlflow/service/run/validators_test.go b/pkg/api/mlflow/service/run/validators_test.go
index d5d5451..5cf9ec8 100644
--- a/pkg/api/mlflow/service/run/validators_test.go
+++ b/pkg/api/mlflow/service/run/validators_test.go
@@ -316,7 +316,7 @@ func TestValidateSearchRunsRequest_Error(t *testing.T) {
 			name:  "NotAllowedViewTypeProperty",
 			error: api.NewInvalidParameterValueError("Invalid run_view_type 'not-allowed-view-type'"),
 			request: &request.SearchRunsRequest{
-				ViewType: request.ViewType(`not-allowed-view-type`),
+				ViewType: request.ViewType("not-allowed-view-type"),
 			},
 		},
 		{
diff --git a/tests/integration/golang/fixtures/app.go b/tests/integration/golang/fixtures/app.go
index f4e2253..482eee5 100644
--- a/tests/integration/golang/fixtures/app.go
+++ b/tests/integration/golang/fixtures/app.go
@@ -77,7 +77,7 @@ func (f AppFixtures) GetApps(
 	if err := f.db.WithContext(ctx).
 		Where("NOT is_archived").
 		Find(&apps).Error; err != nil {
-		return nil, eris.Wrapf(err, "error getting `app` entities")
+		return nil, eris.Wrapf(err, "error getting 'app' entities")
 	}
 	return apps, nil
 }
diff --git a/tests/integration/golang/fixtures/dashboard.go b/tests/integration/golang/fixtures/dashboard.go
index f039256..93be3f6 100644
--- a/tests/integration/golang/fixtures/dashboard.go
+++ b/tests/integration/golang/fixtures/dashboard.go
@@ -78,7 +78,7 @@ func (f DashboardFixtures) GetDashboards(
 	if err := f.db.WithContext(ctx).
 		Where("NOT is_archived").
 		Find(&dashboards).Error; err != nil {
-		return nil, eris.Wrapf(err, "error getting `dashboard` entities")
+		return nil, eris.Wrapf(err, "error getting 'dashboard' entities")
 	}
 	return dashboards, nil
 }
diff --git a/tests/integration/golang/fixtures/experiment.go b/tests/integration/golang/fixtures/experiment.go
index ae1b213..73d8dff 100644
--- a/tests/integration/golang/fixtures/experiment.go
+++ b/tests/integration/golang/fixtures/experiment.go
@@ -88,7 +88,7 @@ func (f ExperimentFixtures) GetTestExperiments(
 	var experiments []models.Experiment
 	if err := f.db.WithContext(ctx).
 		Find(&experiments).Error; err != nil {
-		return nil, eris.Wrapf(err, "error getting `experiment` entities")
+		return nil, eris.Wrapf(err, "error getting 'experiment' entities")
 	}
 	return experiments, nil
 }
diff --git a/tests/integration/golang/helpers/run.go b/tests/integration/golang/helpers/run.go
index 012de4a..58eb64a 100644
--- a/tests/integration/golang/helpers/run.go
+++ b/tests/integration/golang/helpers/run.go
@@ -1,12 +1,8 @@
 package helpers
 
 import (
-	"testing"
 	"time"
 
-	"github.com/stretchr/testify/assert"
-
-	"github.com/G-Research/fasttrackml/pkg/api/mlflow/api/response"
 	"github.com/G-Research/fasttrackml/pkg/api/mlflow/dao/models"
 )
 
@@ -19,37 +15,3 @@ func TransformRunsToActivityMap(runs []*models.Run) map[string]int {
 	}
 	return activity
 }
-
-// CompareExpectedSearchRunsResponseWithActualSearchRunsResponse compares
-// expected response object with the response from POST /runs/search` endpoint.
-func CompareExpectedSearchRunsResponseWithActualSearchRunsResponse(
-	t *testing.T, expectedResponse *response.SearchRunsResponse, actualResponse *response.SearchRunsResponse,
-) {
-	assert.Equal(t, len(expectedResponse.Runs), len(actualResponse.Runs))
-	assert.Equal(t, len(expectedResponse.NextPageToken), len(actualResponse.NextPageToken))
-
-	mappedExpectedResult := make(map[string]*response.RunPartialResponse, len(expectedResponse.Runs))
-	for _, run := range expectedResponse.Runs {
-		mappedExpectedResult[run.Info.ID] = run
-	}
-
-	if actualResponse.Runs != nil && expectedResponse.Runs != nil {
-		for _, actualRun := range actualResponse.Runs {
-			expectedRun, ok := mappedExpectedResult[actualRun.Info.ID]
-			assert.True(t, ok)
-			assert.NotEmpty(t, actualRun.Info.ID)
-			assert.Equal(t, expectedRun.Info.Name, actualRun.Info.Name)
-			assert.Equal(t, expectedRun.Info.Name, actualRun.Info.Name)
-			assert.Equal(t, expectedRun.Info.UserID, actualRun.Info.UserID)
-			assert.Equal(t, expectedRun.Info.Status, actualRun.Info.Status)
-			assert.Equal(t, expectedRun.Info.EndTime, actualRun.Info.EndTime)
-			assert.Equal(t, expectedRun.Info.StartTime, actualRun.Info.StartTime)
-			assert.Equal(t, expectedRun.Info.ArtifactURI, actualRun.Info.ArtifactURI)
-			assert.Equal(t, expectedRun.Info.ExperimentID, actualRun.Info.ExperimentID)
-			assert.Equal(t, expectedRun.Info.LifecycleStage, actualRun.Info.LifecycleStage)
-			assert.Equal(t, expectedRun.Data.Tags, actualRun.Data.Tags)
-			assert.Equal(t, expectedRun.Data.Params, actualRun.Data.Params)
-			assert.Equal(t, expectedRun.Data.Metrics, actualRun.Data.Metrics)
-		}
-	}
-}
diff --git a/tests/integration/golang/mlflow/artifact/list_test.go b/tests/integration/golang/mlflow/artifact/list_test.go
index a914aa8..2ae364b 100644
--- a/tests/integration/golang/mlflow/artifact/list_test.go
+++ b/tests/integration/golang/mlflow/artifact/list_test.go
@@ -152,11 +152,43 @@ func (s *ListArtifactTestSuite) Test_Error() {
 			request: &request.ListArtifactsRequest{},
 		},
 		{
-			name:  "PathIsRelativeAndContains2Dots",
-			error: api.NewInvalidParameterValueError("provided 'path' parameter has to be absolute"),
+			name:  "IncorrectPathProvidedCase1",
+			error: api.NewInvalidParameterValueError("provided 'path' parameter is invalid"),
 			request: &request.ListArtifactsRequest{
 				RunID: "run_id",
-				Path:  "../",
+				Path:  "..",
+			},
+		},
+		{
+			name:  "IncorrectPathProvidedCase2",
+			error: api.NewInvalidParameterValueError("provided 'path' parameter is invalid"),
+			request: &request.ListArtifactsRequest{
+				RunID: "run_id",
+				Path:  "./..",
+			},
+		},
+		{
+			name:  "IncorrectPathProvidedCase3",
+			error: api.NewInvalidParameterValueError("provided 'path' parameter is invalid"),
+			request: &request.ListArtifactsRequest{
+				RunID: "run_id",
+				Path:  "./../",
+			},
+		},
+		{
+			name:  "IncorrectPathProvidedCase4",
+			error: api.NewInvalidParameterValueError("provided 'path' parameter is invalid"),
+			request: &request.ListArtifactsRequest{
+				RunID: "run_id",
+				Path:  "foo/../bar",
+			},
+		},
+		{
+			name:  "IncorrectPathProvidedCase5",
+			error: api.NewInvalidParameterValueError("provided 'path' parameter is invalid"),
+			request: &request.ListArtifactsRequest{
+				RunID: "run_id",
+				Path:  "/foo/../bar",
 			},
 		},
 	}
diff --git a/tests/integration/golang/mlflow/run/delete_test.go b/tests/integration/golang/mlflow/run/delete_test.go
index 01d45af..5bc0782 100644
--- a/tests/integration/golang/mlflow/run/delete_test.go
+++ b/tests/integration/golang/mlflow/run/delete_test.go
@@ -113,7 +113,7 @@ func (s *DeleteRunTestSuite) Test_Error() {
 				&resp,
 			)
 			assert.Nil(s.T(), err)
-			assert.Equal(s.T(), "RESOURCE_DOES_NOT_EXIST: unable to find run 'not-an-id': error getting `run` entity by id: not-an-id: record not found", resp.Error())
+			assert.Equal(s.T(), "RESOURCE_DOES_NOT_EXIST: unable to find run 'not-an-id': error getting 'run' entity by id: not-an-id: record not found", resp.Error())
 		})
 	}
 }
diff --git a/tests/integration/golang/mlflow/run/get_test.go b/tests/integration/golang/mlflow/run/get_test.go
index e1f3c24..1e6460a 100644
--- a/tests/integration/golang/mlflow/run/get_test.go
+++ b/tests/integration/golang/mlflow/run/get_test.go
@@ -174,7 +174,7 @@ func (s *GetRunTestSuite) Test_Error() {
 				RunID: "id",
 			},
 			error: api.NewResourceDoesNotExistError(
-				"unable to find run 'id': error getting `run` entity by id: id: record not found",
+				"unable to find run 'id': error getting 'run' entity by id: id: record not found",
 			),
 		},
 	}
diff --git a/tests/integration/golang/mlflow/run/log_metric_test.go b/tests/integration/golang/mlflow/run/log_metric_test.go
index 3f97f3c..64161f6 100644
--- a/tests/integration/golang/mlflow/run/log_metric_test.go
+++ b/tests/integration/golang/mlflow/run/log_metric_test.go
@@ -127,7 +127,7 @@ func (s *LogMetricTestSuite) Test_Error() {
 				Timestamp: 123456789,
 			},
 			error: api.NewResourceDoesNotExistError(
-				"unable to find run 'id': error getting `run` entity by id: id: record not found",
+				"unable to find run 'id': error getting 'run' entity by id: id: record not found",
 			),
 		},
 		{
diff --git a/tests/integration/golang/mlflow/run/restore_test.go b/tests/integration/golang/mlflow/run/restore_test.go
index fb178d1..2e41c87 100644
--- a/tests/integration/golang/mlflow/run/restore_test.go
+++ b/tests/integration/golang/mlflow/run/restore_test.go
@@ -140,7 +140,7 @@ func (s *RestoreRunTestSuite) Test_Error() {
 				RunID: "id",
 			},
 			error: api.NewResourceDoesNotExistError(
-				"unable to find run 'id': error getting `run` entity by id: id: record not found",
+				"unable to find run 'id': error getting 'run' entity by id: id: record not found",
 			),
 		},
 	}
diff --git a/tests/integration/golang/mlflow/run/search_test.go b/tests/integration/golang/mlflow/run/search_test.go
index df0456f..f815c5d 100644
--- a/tests/integration/golang/mlflow/run/search_test.go
+++ b/tests/integration/golang/mlflow/run/search_test.go
@@ -2884,7 +2884,33 @@ func (s *SearchTestSuite) Test_Ok() {
 				&resp,
 			)
 			assert.Nil(s.T(), err)
-			helpers.CompareExpectedSearchRunsResponseWithActualSearchRunsResponse(s.T(), tt.response, resp)
+			assert.Equal(s.T(), len(tt.response.Runs), len(resp.Runs))
+			assert.Equal(s.T(), len(tt.response.NextPageToken), len(resp.NextPageToken))
+
+			mappedExpectedResult := make(map[string]*response.RunPartialResponse, len(tt.response.Runs))
+			for _, run := range tt.response.Runs {
+				mappedExpectedResult[run.Info.ID] = run
+			}
+
+			if tt.response.Runs != nil && resp.Runs != nil {
+				for _, actualRun := range resp.Runs {
+					expectedRun, ok := mappedExpectedResult[actualRun.Info.ID]
+					assert.True(s.T(), ok)
+					assert.NotEmpty(s.T(), actualRun.Info.ID)
+					assert.Equal(s.T(), expectedRun.Info.Name, actualRun.Info.Name)
+					assert.Equal(s.T(), expectedRun.Info.Name, actualRun.Info.Name)
+					assert.Equal(s.T(), expectedRun.Info.UserID, actualRun.Info.UserID)
+					assert.Equal(s.T(), expectedRun.Info.Status, actualRun.Info.Status)
+					assert.Equal(s.T(), expectedRun.Info.EndTime, actualRun.Info.EndTime)
+					assert.Equal(s.T(), expectedRun.Info.StartTime, actualRun.Info.StartTime)
+					assert.Equal(s.T(), expectedRun.Info.ArtifactURI, actualRun.Info.ArtifactURI)
+					assert.Equal(s.T(), expectedRun.Info.ExperimentID, actualRun.Info.ExperimentID)
+					assert.Equal(s.T(), expectedRun.Info.LifecycleStage, actualRun.Info.LifecycleStage)
+					assert.Equal(s.T(), expectedRun.Data.Tags, actualRun.Data.Tags)
+					assert.Equal(s.T(), expectedRun.Data.Params, actualRun.Data.Params)
+					assert.Equal(s.T(), expectedRun.Data.Metrics, actualRun.Data.Metrics)
+				}
+			}
 		})
 	}
 }
diff --git a/tests/integration/golang/mlflow/run/update_test.go b/tests/integration/golang/mlflow/run/update_test.go
index f835303..0c1ba9c 100644
--- a/tests/integration/golang/mlflow/run/update_test.go
+++ b/tests/integration/golang/mlflow/run/update_test.go
@@ -122,7 +122,7 @@ func (s *UpdateRunTestSuite) Test_Error() {
 				RunID: "1",
 			},
 			error: api.NewResourceDoesNotExistError(
-				"unable to find run '1': error getting `run` entity by id: 1: record not found",
+				"unable to find run '1': error getting 'run' entity by id: 1: record not found",
 			),
 		},
 	}