Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions api/restHandler/app/appList/AppListingRestHandler.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import (
"github.com/gorilla/mux"
"go.opentelemetry.io/otel"
"go.uber.org/zap"
"gopkg.in/go-playground/validator.v9"
"net/http"
"strconv"
"time"
Expand Down Expand Up @@ -98,6 +99,7 @@ type AppListingRestHandlerImpl struct {
k8sApplicationService k8sApplication.K8sApplicationService
deploymentConfigService common2.DeploymentConfigService
resourceTreeService resourceTree.Service
validator *validator.Validate
}

type AppStatus struct {
Expand Down Expand Up @@ -141,6 +143,7 @@ func NewAppListingRestHandlerImpl(appListingService app.AppListingService,
k8sApplicationService: k8sApplicationService,
deploymentConfigService: deploymentConfigService,
resourceTreeService: resourceTreeService,
validator: validator.New(),
}
return appListingHandler
}
Expand Down Expand Up @@ -276,6 +279,25 @@ func (handler AppListingRestHandlerImpl) FetchJobOverviewCiPipelines(w http.Resp
common.WriteJsonResp(w, err, jobCi, http.StatusOK)
}

// validateAndNormalizeFetchAppListingRequest applies request-level validation first,
// then tag-filter business validation, and finally normalization.
func (handler AppListingRestHandlerImpl) validateAndNormalizeFetchAppListingRequest(w http.ResponseWriter, r *http.Request, fetchAppListingRequest *app.FetchAppListingRequest) bool {
err := handler.validator.Struct(*fetchAppListingRequest)
if err != nil {
handler.logger.Errorw("validation err, FetchAppsByEnvironment", "err", err, "payload", fetchAppListingRequest)
common.HandleValidationErrors(w, r, err)
return false
}
err = handler.appListingService.ValidateTagFilters(fetchAppListingRequest.TagFilters)
if err != nil {
handler.logger.Errorw("request err, ValidateTagFilters", "err", err, "payload", fetchAppListingRequest.TagFilters)
common.WriteJsonResp(w, err, nil, http.StatusBadRequest)
return false
}
fetchAppListingRequest.TagFilters = handler.appListingService.NormalizeTagFilters(fetchAppListingRequest.TagFilters)
return true
}

func (handler AppListingRestHandlerImpl) FetchAppsByEnvironmentV2(w http.ResponseWriter, r *http.Request) {
//Allow CORS here By * or specific origin
util3.SetupCorsOriginHeader(&w)
Expand Down Expand Up @@ -331,6 +353,9 @@ func (handler AppListingRestHandlerImpl) FetchAppsByEnvironmentV2(w http.Respons
common.WriteJsonResp(w, err, nil, http.StatusBadRequest)
return
}
if !handler.validateAndNormalizeFetchAppListingRequest(w, r, &fetchAppListingRequest) {
return
}
newCtx, span = otel.Tracer("fetchAppListingRequest").Start(newCtx, "GetNamespaceClusterMapping")
_, _, err = fetchAppListingRequest.GetNamespaceClusterMapping()
span.End()
Expand Down
131 changes: 120 additions & 11 deletions internal/sql/repository/helper/AppListingRepositoryQueryBuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/devtron-labs/devtron/util"
"github.com/go-pg/pg"
"go.uber.org/zap"
"strings"
)

type AppType int
Expand All @@ -44,32 +45,69 @@ func NewAppListingRepositoryQueryBuilder(logger *zap.SugaredLogger) AppListingRe
}

type AppListingFilter struct {
Environments []int `json:"environments"`
Statuses []string `json:"statutes"`
Teams []int `json:"teams"`
AppStatuses []string `json:"appStatuses"`
AppNameSearch string `json:"appNameSearch"`
SortOrder SortOrder `json:"sortOrder"`
SortBy SortBy `json:"sortBy"`
Offset int `json:"offset"`
Size int `json:"size"`
DeploymentGroupId int `json:"deploymentGroupId"`
AppIds []int `json:"-"` // internal use only
Environments []int `json:"environments"`
Statuses []string `json:"statutes"`
Teams []int `json:"teams"`
AppStatuses []string `json:"appStatuses"`
TagFilters []TagFilter `json:"tagFilters"`
AppNameSearch string `json:"appNameSearch"`
SortOrder SortOrder `json:"sortOrder"`
SortBy SortBy `json:"sortBy"`
Offset int `json:"offset"`
Size int `json:"size"`
DeploymentGroupId int `json:"deploymentGroupId"`
AppIds []int `json:"-"` // internal use only
}

type SortBy string
type SortOrder string
type TagFilterOperator string

// TagFilter holds one row of label filter sent by UI.
// key is always required.
// value is required for EQUALS/DOES_NOT_EQUAL/CONTAINS/DOES_NOT_CONTAIN.
// value must be absent for EXISTS/DOES_NOT_EXIST.
type TagFilter struct {
Key string `json:"key" validate:"required"`
Operator TagFilterOperator `json:"operator" validate:"required"`
Value *string `json:"value"`
}

const (
Asc SortOrder = "ASC"
Desc SortOrder = "DESC"
)

const (
TagFilterOperatorEquals TagFilterOperator = "EQUALS"
TagFilterOperatorDoesNotEqual TagFilterOperator = "DOES_NOT_EQUAL"
TagFilterOperatorContains TagFilterOperator = "CONTAINS"
TagFilterOperatorDoesNotContain TagFilterOperator = "DOES_NOT_CONTAIN"
TagFilterOperatorExists TagFilterOperator = "EXISTS"
TagFilterOperatorDoesNotExist TagFilterOperator = "DOES_NOT_EXIST"
)

const (
AppNameSortBy SortBy = "appNameSort"
LastDeployedSortBy = "lastDeployedSort"
)

var likePatternEscaper = strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_")

func (operator TagFilterOperator) IsValid() bool {
switch operator {
case TagFilterOperatorEquals,
TagFilterOperatorDoesNotEqual,
TagFilterOperatorContains,
TagFilterOperatorDoesNotContain,
TagFilterOperatorExists,
TagFilterOperatorDoesNotExist:
return true
default:
return false
}
}

func (impl AppListingRepositoryQueryBuilder) BuildJobListingQuery(appIDs []int, statuses []string, environmentIds []int, sortOrder string) (string, []interface{}) {
var queryParams []interface{}
query := `select ci_pipeline.name as ci_pipeline_name,ci_pipeline.id as ci_pipeline_id,app.id as job_id,app.display_name
Expand Down Expand Up @@ -273,13 +311,84 @@ func (impl AppListingRepositoryQueryBuilder) buildAppListingWhereCondition(appLi
whereCondition += " and aps.status IN (?) "
queryParams = append(queryParams, pg.In(appStatusExcludingNotDeployed))
}

// Tag filters are AND-combined for now as requested by product.
// Each row translates to a correlated EXISTS/NOT EXISTS on app_label.
tagWhereCondition, tagQueryParams := impl.buildTagFiltersWhereConditionAND(appListingFilter.TagFilters)
whereCondition += tagWhereCondition
queryParams = append(queryParams, tagQueryParams...)

if len(appListingFilter.AppIds) > 0 {
whereCondition += " and a.id IN (?) "
queryParams = append(queryParams, pg.In(appListingFilter.AppIds))
}
return whereCondition, queryParams
}

func (impl AppListingRepositoryQueryBuilder) buildTagFiltersWhereConditionAND(tagFilters []TagFilter) (string, []interface{}) {
if len(tagFilters) == 0 {
return "", nil
}
var queryBuilder strings.Builder
queryParams := make([]interface{}, 0, len(tagFilters)*2)
for _, tagFilter := range tagFilters {
predicate, predicateParams := impl.buildTagFilterPredicate(tagFilter)
queryBuilder.WriteString(" and ")
queryBuilder.WriteString(predicate)
queryParams = append(queryParams, predicateParams...)
}
return queryBuilder.String(), queryParams
}

// buildTagFilterPredicate converts one UI tag filter row into a SQL predicate.
// Operator behavior (all case-sensitive):
// - EQUALS: key exists with exact value match.
// - DOES_NOT_EQUAL: key exists with at least one value different from target.
// - CONTAINS: key exists with at least one value containing target substring.
// - DOES_NOT_CONTAIN: key exists with at least one value not containing target substring.
// - EXISTS: key exists.
// - DOES_NOT_EXIST: key does not exist.
func (impl AppListingRepositoryQueryBuilder) buildTagFilterPredicate(tagFilter TagFilter) (string, []interface{}) {
value := ""
if tagFilter.Value != nil {
value = *tagFilter.Value
}
switch tagFilter.Operator {
case TagFilterOperatorEquals:
return "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ? and al.value = ?)",
[]interface{}{tagFilter.Key, value}
case TagFilterOperatorDoesNotEqual:
// Best-practice semantics for multi-value keys:
// include app when key exists and at least one value is different from target.
return "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ? and al.value <> ?)",
[]interface{}{tagFilter.Key, value}
case TagFilterOperatorContains:
return "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ? and al.value LIKE ? ESCAPE '\\')",
[]interface{}{tagFilter.Key, buildContainsPattern(value)}
case TagFilterOperatorDoesNotContain:
// Best-practice semantics for multi-value keys:
// include app when key exists and at least one value does not contain target.
return "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ? and al.value NOT LIKE ? ESCAPE '\\')",
[]interface{}{tagFilter.Key, buildContainsPattern(value)}
case TagFilterOperatorExists:
return "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ?)",
[]interface{}{tagFilter.Key}
case TagFilterOperatorDoesNotExist:
return "NOT EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ?)",
[]interface{}{tagFilter.Key}
default:
// Invalid operator should never reach here due request validation.
// Returning false condition keeps query safe if validation is bypassed.
return "1 = 0", nil
}
}

func buildContainsPattern(value string) string {
// Escape SQL LIKE wildcard chars so "contains" behaves like plain substring search.
escaped := likePatternEscaper.Replace(value)
return "%" + escaped + "%"
}

func GetCommaSepratedString[T int | string](request []T) string {
respString := ""
for i, item := range request {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package helper

import (
"go.uber.org/zap"
"testing"

"github.com/stretchr/testify/assert"
)

func stringPointer(value string) *string {
return &value
}

func TestBuildAppListingWhereCondition_WithTagFiltersAnd(t *testing.T) {

Check warning on line 14 in internal/sql/repository/helper/AppListingRepositoryQueryBuilder_tag_filters_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "TestBuildAppListingWhereCondition_WithTagFiltersAnd" to match the regular expression ^(_|[a-zA-Z0-9]+)$

See more on https://sonarcloud.io/project/issues?id=devtron-labs_devtron&issues=AZ0FWYWn1jy-UnSmj-lB&open=AZ0FWYWn1jy-UnSmj-lB&pullRequest=6938
queryBuilder := NewAppListingRepositoryQueryBuilder(zap.NewNop().Sugar())
whereClause, queryParams := queryBuilder.buildAppListingWhereCondition(AppListingFilter{
TagFilters: []TagFilter{
{Key: "owner", Operator: TagFilterOperatorEquals, Value: stringPointer("James")},
{Key: "env", Operator: TagFilterOperatorDoesNotContain, Value: stringPointer("pro_d%")},
{Key: "team", Operator: TagFilterOperatorExists, Value: nil},
{Key: "zone", Operator: TagFilterOperatorDoesNotExist, Value: nil},
},
})

assert.Contains(t, whereClause, "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ? and al.value = ?)")
assert.Contains(t, whereClause, "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ? and al.value NOT LIKE ? ESCAPE '\\')")
assert.Contains(t, whereClause, "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ?)")
assert.Contains(t, whereClause, "NOT EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ?)")
assert.Len(t, queryParams, 8)
assert.Equal(t, true, queryParams[0])
assert.Equal(t, CustomApp, queryParams[1])
assert.Equal(t, "owner", queryParams[2])
assert.Equal(t, "James", queryParams[3])
assert.Equal(t, "env", queryParams[4])
assert.Equal(t, "%pro\\_d\\%%", queryParams[5])
assert.Equal(t, "team", queryParams[6])
assert.Equal(t, "zone", queryParams[7])
}

func TestBuildTagFilterPredicate_DoesNotEqualRequiresKeyAndDifferentValue(t *testing.T) {

Check warning on line 40 in internal/sql/repository/helper/AppListingRepositoryQueryBuilder_tag_filters_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "TestBuildTagFilterPredicate_DoesNotEqualRequiresKeyAndDifferentValue" to match the regular expression ^(_|[a-zA-Z0-9]+)$

See more on https://sonarcloud.io/project/issues?id=devtron-labs_devtron&issues=AZ0FWYWn1jy-UnSmj-lC&open=AZ0FWYWn1jy-UnSmj-lC&pullRequest=6938
queryBuilder := NewAppListingRepositoryQueryBuilder(zap.NewNop().Sugar())
value := "mayank"

predicate, queryParams := queryBuilder.buildTagFilterPredicate(TagFilter{
Key: "owner",
Operator: TagFilterOperatorDoesNotEqual,
Value: &value,
})

assert.Equal(t, "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ? and al.value <> ?)", predicate)
assert.Equal(t, []interface{}{"owner", "mayank"}, queryParams)
}

func TestBuildTagFilterPredicate_DoesNotContainRequiresKeyAndNotLike(t *testing.T) {

Check warning on line 54 in internal/sql/repository/helper/AppListingRepositoryQueryBuilder_tag_filters_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "TestBuildTagFilterPredicate_DoesNotContainRequiresKeyAndNotLike" to match the regular expression ^(_|[a-zA-Z0-9]+)$

See more on https://sonarcloud.io/project/issues?id=devtron-labs_devtron&issues=AZ0FWYWn1jy-UnSmj-lD&open=AZ0FWYWn1jy-UnSmj-lD&pullRequest=6938
queryBuilder := NewAppListingRepositoryQueryBuilder(zap.NewNop().Sugar())
value := "may"

predicate, queryParams := queryBuilder.buildTagFilterPredicate(TagFilter{
Key: "owner",
Operator: TagFilterOperatorDoesNotContain,
Value: &value,
})

assert.Equal(t, "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ? and al.value NOT LIKE ? ESCAPE '\\')", predicate)
assert.Equal(t, []interface{}{"owner", "%may%"}, queryParams)
}
Loading
Loading