// Copyright 2023 Harness, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package database import ( "context" "fmt" "strings" "github.com/harness/gitness/app/store" "github.com/harness/gitness/store/database" "github.com/harness/gitness/store/database/dbtx" "github.com/harness/gitness/types" "github.com/harness/gitness/types/enum" "github.com/Masterminds/squirrel" "github.com/guregu/null" "github.com/jmoiron/sqlx" "github.com/pkg/errors" "github.com/rs/zerolog/log" ) const ( gitspaceConfigInsertColumns = ` gconf_uid, gconf_display_name, gconf_ide, gconf_infra_provider_resource_id, gconf_code_auth_type, gconf_code_auth_id, gconf_code_repo_type, gconf_code_repo_is_private, gconf_code_repo_url, gconf_devcontainer_path, gconf_branch, gconf_user_uid, gconf_space_id, gconf_created, gconf_updated, gconf_is_deleted, gconf_code_repo_ref, gconf_ssh_token_identifier, gconf_created_by, gconf_is_marked_for_deletion ` gitspaceConfigsTable = `gitspace_configs` ReturningClause = "RETURNING " gitspaceConfigSelectColumns = "gconf_id," + gitspaceConfigInsertColumns ) type gitspaceConfig struct { ID int64 `db:"gconf_id"` Identifier string `db:"gconf_uid"` Name string `db:"gconf_display_name"` IDE enum.IDEType `db:"gconf_ide"` InfraProviderResourceID int64 `db:"gconf_infra_provider_resource_id"` CodeAuthType string `db:"gconf_code_auth_type"` CodeRepoRef null.String `db:"gconf_code_repo_ref"` CodeAuthID string `db:"gconf_code_auth_id"` CodeRepoType enum.GitspaceCodeRepoType `db:"gconf_code_repo_type"` CodeRepoIsPrivate bool `db:"gconf_code_repo_is_private"` CodeRepoURL string `db:"gconf_code_repo_url"` DevcontainerPath null.String `db:"gconf_devcontainer_path"` Branch string `db:"gconf_branch"` // TODO: migrate to principal int64 id to use principal cache and consistent with Harness code. UserUID string `db:"gconf_user_uid"` SpaceID int64 `db:"gconf_space_id"` Created int64 `db:"gconf_created"` Updated int64 `db:"gconf_updated"` IsDeleted bool `db:"gconf_is_deleted"` SSHTokenIdentifier string `db:"gconf_ssh_token_identifier"` CreatedBy null.Int `db:"gconf_created_by"` IsMarkedForDeletion bool `db:"gconf_is_marked_for_deletion"` } type gitspaceConfigWithLatestInstance struct { gitspaceConfig // gitspace instance information gitspaceInstance } var _ store.GitspaceConfigStore = (*gitspaceConfigStore)(nil) // NewGitspaceConfigStore returns a new GitspaceConfigStore. func NewGitspaceConfigStore( db *sqlx.DB, pCache store.PrincipalInfoCache, rCache store.InfraProviderResourceCache, ) store.GitspaceConfigStore { return &gitspaceConfigStore{ db: db, pCache: pCache, rCache: rCache, } } type gitspaceConfigStore struct { db *sqlx.DB pCache store.PrincipalInfoCache rCache store.InfraProviderResourceCache } func (s gitspaceConfigStore) Count(ctx context.Context, filter *types.GitspaceFilter) (int64, error) { db := dbtx.GetAccessor(ctx, s.db) gitsSelectStr := getLatestInstanceQuery() countStmt := squirrel.Select("COUNT(*)"). From(gitspaceConfigsTable). LeftJoin("(" + gitsSelectStr + ") AS gits ON gitspace_configs.gconf_id = gits.gits_gitspace_config_id AND gits.rn = 1"). PlaceholderFormat(squirrel.Dollar) countStmt = addGitspaceFilter(countStmt, filter) countStmt = addGitspaceQueryFilter(countStmt, filter.QueryFilter) sql, args, err := countStmt.ToSql() if err != nil { return 0, errors.Wrap(err, "Failed to convert squirrel builder to sql") } var count int64 err = db.QueryRowContext(ctx, sql, args...).Scan(&count) if err != nil { return 0, database.ProcessSQLErrorf(ctx, err, "Failed executing custom count query") } return count, nil } func (s gitspaceConfigStore) Find(ctx context.Context, id int64, includeDeleted bool) (*types.GitspaceConfig, error) { stmt := database.Builder. Select(gitspaceConfigSelectColumns). From(gitspaceConfigsTable). Where("gconf_id = ?", id) //nolint:goconst if !includeDeleted { stmt = stmt.Where("gconf_is_deleted = ?", false) } dst := new(gitspaceConfig) sql, args, err := stmt.ToSql() if err != nil { return nil, errors.Wrap(err, "Failed to convert squirrel builder to sql") } db := dbtx.GetAccessor(ctx, s.db) if err := db.GetContext(ctx, dst, sql, args...); err != nil { return nil, database.ProcessSQLErrorf(ctx, err, "Failed to find gitspace config for %d", id) } return s.mapDBToGitspaceConfig(ctx, dst) } func (s gitspaceConfigStore) FindByIdentifier( ctx context.Context, spaceID int64, identifier string, ) (*types.GitspaceConfig, error) { stmt := database.Builder. Select(gitspaceConfigSelectColumns). From(gitspaceConfigsTable). Where("LOWER(gconf_uid) = $1", strings.ToLower(identifier)). Where("gconf_space_id = $2", spaceID). Where("gconf_is_deleted = $3", false) sql, args, err := stmt.ToSql() if err != nil { return nil, errors.Wrap(err, "Failed to convert squirrel builder to sql") } dst := new(gitspaceConfig) db := dbtx.GetAccessor(ctx, s.db) if err := db.GetContext(ctx, dst, sql, args...); err != nil { return nil, database.ProcessSQLErrorf(ctx, err, "Failed to find gitspace config for %s", identifier) } return s.mapDBToGitspaceConfig(ctx, dst) } func (s gitspaceConfigStore) Create(ctx context.Context, gitspaceConfig *types.GitspaceConfig) error { stmt := database.Builder. Insert(gitspaceConfigsTable). Columns(gitspaceConfigInsertColumns). Values( gitspaceConfig.Identifier, gitspaceConfig.Name, gitspaceConfig.IDE, gitspaceConfig.InfraProviderResource.ID, gitspaceConfig.CodeRepo.AuthType, gitspaceConfig.CodeRepo.AuthID, gitspaceConfig.CodeRepo.Type, gitspaceConfig.CodeRepo.IsPrivate, gitspaceConfig.CodeRepo.URL, gitspaceConfig.DevcontainerPath, gitspaceConfig.Branch, gitspaceConfig.GitspaceUser.Identifier, gitspaceConfig.SpaceID, gitspaceConfig.Created, gitspaceConfig.Updated, gitspaceConfig.IsDeleted, gitspaceConfig.CodeRepo.Ref, gitspaceConfig.SSHTokenIdentifier, gitspaceConfig.GitspaceUser.ID, gitspaceConfig.IsMarkedForDeletion, ). Suffix(ReturningClause + "gconf_id") sql, args, err := stmt.ToSql() if err != nil { return errors.Wrap(err, "Failed to convert squirrel builder to sql") } db := dbtx.GetAccessor(ctx, s.db) if err = db.QueryRowContext(ctx, sql, args...).Scan(&gitspaceConfig.ID); err != nil { return database.ProcessSQLErrorf( ctx, err, "gitspace config create query failed for %s", gitspaceConfig.Identifier) } return nil } func (s gitspaceConfigStore) Update(ctx context.Context, gitspaceConfig *types.GitspaceConfig) error { dbGitspaceConfig := mapToInternalGitspaceConfig(gitspaceConfig) stmt := database.Builder. Update(gitspaceConfigsTable). Set("gconf_display_name", dbGitspaceConfig.Name). Set("gconf_ide", dbGitspaceConfig.IDE). Set("gconf_updated", dbGitspaceConfig.Updated). Set("gconf_infra_provider_resource_id", dbGitspaceConfig.InfraProviderResourceID). Set("gconf_is_deleted", dbGitspaceConfig.IsDeleted). Set("gconf_is_marked_for_deletion", dbGitspaceConfig.IsMarkedForDeletion). Where("gconf_id = ?", gitspaceConfig.ID) sql, args, err := stmt.ToSql() if err != nil { return errors.Wrap(err, "Failed to convert squirrel builder to sql") } db := dbtx.GetAccessor(ctx, s.db) if _, err := db.ExecContext(ctx, sql, args...); err != nil { return database.ProcessSQLErrorf( ctx, err, "Failed to update gitspace config for %s", gitspaceConfig.Identifier) } return nil } func mapToInternalGitspaceConfig(config *types.GitspaceConfig) *gitspaceConfig { return &gitspaceConfig{ ID: config.ID, Identifier: config.Identifier, Name: config.Name, IDE: config.IDE, InfraProviderResourceID: config.InfraProviderResource.ID, CodeAuthType: config.CodeRepo.AuthType, CodeAuthID: config.CodeRepo.AuthID, CodeRepoIsPrivate: config.CodeRepo.IsPrivate, CodeRepoType: config.CodeRepo.Type, CodeRepoRef: null.StringFromPtr(config.CodeRepo.Ref), CodeRepoURL: config.CodeRepo.URL, DevcontainerPath: null.StringFromPtr(config.DevcontainerPath), Branch: config.Branch, UserUID: config.GitspaceUser.Identifier, SpaceID: config.SpaceID, IsDeleted: config.IsDeleted, IsMarkedForDeletion: config.IsMarkedForDeletion, Created: config.Created, Updated: config.Updated, SSHTokenIdentifier: config.SSHTokenIdentifier, CreatedBy: null.IntFromPtr(config.GitspaceUser.ID), } } // ListWithLatestInstance returns gitspace configs for the given filter with the latest gitspace instance information. func (s gitspaceConfigStore) ListWithLatestInstance( ctx context.Context, filter *types.GitspaceFilter, ) ([]*types.GitspaceConfig, error) { gitsSelectStr := getLatestInstanceQuery() stmt := squirrel.Select( gitspaceConfigSelectColumns, gitspaceInstanceSelectColumns). From(gitspaceConfigsTable). LeftJoin("(" + gitsSelectStr + ") AS gits ON gitspace_configs.gconf_id = gits.gits_gitspace_config_id AND gits.rn = 1"). PlaceholderFormat(squirrel.Dollar) stmt = addGitspaceFilter(stmt, filter) stmt = addGitspaceQueryFilter(stmt, filter.QueryFilter) stmt = addOrderBy(stmt, filter) stmt = stmt.Limit(database.Limit(filter.QueryFilter.Size)) stmt = stmt.Offset(database.Offset(filter.QueryFilter.Page, filter.QueryFilter.Size)) sql, args, err := stmt.ToSql() if err != nil { return nil, errors.Wrap(err, "Failed to convert squirrel builder to sql") } db := dbtx.GetAccessor(ctx, s.db) var dst []*gitspaceConfigWithLatestInstance if err = db.SelectContext(ctx, &dst, sql, args...); err != nil { return nil, database.ProcessSQLErrorf(ctx, err, "Failed executing list gitspace config query") } return s.ToGitspaceConfigs(ctx, dst) } func getLatestInstanceQuery() string { return fmt.Sprintf("SELECT %s, %s FROM %s", gitspaceInstanceSelectColumns, "ROW_NUMBER() OVER (PARTITION BY gits_gitspace_config_id ORDER BY gits_created DESC) AS rn", gitspaceInstanceTable, ) } func addGitspaceFilter(stmt squirrel.SelectBuilder, filter *types.GitspaceFilter) squirrel.SelectBuilder { stmt = stmt.Where(squirrel.Gt{"gits_id": 0}) if filter.Deleted != nil { stmt = stmt.Where(squirrel.Eq{"gconf_is_deleted": filter.Deleted}) } if filter.Owner == enum.GitspaceOwnerSelf && filter.UserIdentifier != "" { stmt = stmt.Where(squirrel.Eq{"gconf_user_uid": filter.UserIdentifier}) } if filter.MarkedForDeletion != nil { stmt = stmt.Where(squirrel.Eq{"gconf_is_marked_for_deletion": filter.MarkedForDeletion}) } if len(filter.SpaceIDs) > 0 { stmt = stmt.Where(squirrel.Eq{"gconf_space_id": filter.SpaceIDs}) } if filter.LastHeartBeatBefore > 0 { stmt = stmt.Where(squirrel.Lt{"gits_last_heartbeat": filter.LastHeartBeatBefore}) } if filter.LastUsedBefore > 0 { stmt = stmt.Where(squirrel.Lt{"gits_last_used": filter.LastUsedBefore}) } if filter.LastUpdatedBefore > 0 { stmt = stmt.Where(squirrel.Lt{"gits_updated": filter.LastUpdatedBefore}) } if len(filter.GitspaceFilterStates) > 0 && len(filter.States) > 0 { log.Warn().Msgf("both view list filter and states are set for gitspace, the states[] are ignored") } if len(filter.GitspaceFilterStates) > 0 { instanceStateTypes := make([]enum.GitspaceInstanceStateType, 0, len(filter.GitspaceFilterStates)) for _, state := range filter.GitspaceFilterStates { switch state { case enum.GitspaceFilterStateError: instanceStateTypes = append(instanceStateTypes, enum.GitspaceInstanceStateError, enum.GitspaceInstanceStateCleaned) case enum.GitspaceFilterStateRunning: instanceStateTypes = append(instanceStateTypes, enum.GitspaceInstanceStateRunning) case enum.GitspaceFilterStateStopped: instanceStateTypes = append(instanceStateTypes, enum.GitspaceInstanceStateDeleted) } } stmt = stmt.Where(squirrel.Eq{"gits_state": instanceStateTypes}) } else if len(filter.States) > 0 { stmt = stmt.Where(squirrel.Eq{"gits_state": filter.States}) } return stmt } func addOrderBy(stmt squirrel.SelectBuilder, filter *types.GitspaceFilter) squirrel.SelectBuilder { switch filter.Sort { case enum.GitspaceSortLastUsed: return stmt.OrderBy("gits_last_used " + filter.Order.String()) case enum.GitspaceSortCreated: return stmt.OrderBy("gconf_created " + filter.Order.String()) case enum.GitspaceSortLastActivated: return stmt.OrderBy("gits_created " + filter.Order.String()) default: return stmt.OrderBy("gits_created " + filter.Order.String()) } } func (s gitspaceConfigStore) FindAll(ctx context.Context, ids []int64) ([]*types.GitspaceConfig, error) { stmt := database.Builder. Select(gitspaceConfigSelectColumns). From(gitspaceConfigsTable). Where(squirrel.Eq{"gconf_id": ids}). //nolint:goconst Where("gconf_is_deleted = ?", false) var dst []*gitspaceConfig sql, args, err := stmt.ToSql() if err != nil { return nil, errors.Wrap(err, "Failed to convert squirrel builder to sql") } db := dbtx.GetAccessor(ctx, s.db) if err := db.SelectContext(ctx, &dst, sql, args...); err != nil { return nil, database.ProcessSQLErrorf(ctx, err, "Failed to find all gitspace configs for %v", ids) } return s.mapToGitspaceConfigs(ctx, dst) } func (s gitspaceConfigStore) mapDBToGitspaceConfig( ctx context.Context, in *gitspaceConfig, ) (*types.GitspaceConfig, error) { codeRepo := types.CodeRepo{ URL: in.CodeRepoURL, Ref: in.CodeRepoRef.Ptr(), Type: in.CodeRepoType, Branch: in.Branch, DevcontainerPath: in.DevcontainerPath.Ptr(), IsPrivate: in.CodeRepoIsPrivate, AuthType: in.CodeAuthType, AuthID: in.CodeAuthID, } var result = &types.GitspaceConfig{ ID: in.ID, Identifier: in.Identifier, Name: in.Name, IDE: in.IDE, SpaceID: in.SpaceID, Created: in.Created, Updated: in.Updated, SSHTokenIdentifier: in.SSHTokenIdentifier, IsMarkedForDeletion: in.IsMarkedForDeletion, IsDeleted: in.IsDeleted, CodeRepo: codeRepo, GitspaceUser: types.GitspaceUser{ ID: in.CreatedBy.Ptr(), Identifier: in.UserUID}, } if result.GitspaceUser.ID != nil { author, _ := s.pCache.Get(ctx, *result.GitspaceUser.ID) if author != nil { result.GitspaceUser.DisplayName = author.DisplayName result.GitspaceUser.Email = author.Email } } if resource, err := s.rCache.Get(ctx, in.InfraProviderResourceID); err == nil { result.InfraProviderResource = *resource } else { return nil, fmt.Errorf("couldn't set resource to the config in DB: %s", in.Identifier) } return result, nil } func (s gitspaceConfigStore) ToGitspaceConfig( ctx context.Context, in *gitspaceConfigWithLatestInstance, ) (*types.GitspaceConfig, error) { var result, err = s.mapDBToGitspaceConfig(ctx, &in.gitspaceConfig) if err != nil { return nil, err } instance, err := mapDBToGitspaceInstance(ctx, &in.gitspaceInstance) if err != nil { log.Ctx(ctx).Error().Err(err).Msgf("Failed to convert to gitspace instance, gitspace configID: %d", in.gitspaceInstance.ID, ) instance = nil } if instance != nil { gitspaceStateType, err2 := instance.GetGitspaceState() if err2 != nil { return nil, err2 } result.State = gitspaceStateType } else { result.State = enum.GitspaceStateUninitialized } result.GitspaceInstance = instance return result, nil } func (s gitspaceConfigStore) mapToGitspaceConfigs( ctx context.Context, configs []*gitspaceConfig, ) ([]*types.GitspaceConfig, error) { var err error res := make([]*types.GitspaceConfig, len(configs)) for i := range configs { res[i], err = s.mapDBToGitspaceConfig(ctx, configs[i]) if err != nil { return nil, err } } return res, nil } func (s gitspaceConfigStore) ToGitspaceConfigs( ctx context.Context, configs []*gitspaceConfigWithLatestInstance, ) ([]*types.GitspaceConfig, error) { var err error res := make([]*types.GitspaceConfig, len(configs)) for i := range configs { res[i], err = s.ToGitspaceConfig(ctx, configs[i]) if err != nil { return nil, err } } return res, nil } func addGitspaceQueryFilter(stmt squirrel.SelectBuilder, filter types.ListQueryFilter) squirrel.SelectBuilder { if filter.Query != "" { stmt = stmt.Where(squirrel.Or{ squirrel.Expr(PartialMatch("gconf_uid", filter.Query)), squirrel.Expr(PartialMatch("gconf_display_name", filter.Query)), }) } return stmt }