Support soft delete, restore and purge repos plus a cleanup job for old deleted repos (#1005)

This commit is contained in:
Atefeh Mohseni-Ejiyeh 2024-02-14 01:39:39 +00:00 committed by Harness
parent 143b0bf5ad
commit fc9e77c91c
27 changed files with 711 additions and 90 deletions

View File

@ -20,6 +20,7 @@ import (
apiauth "github.com/harness/gitness/app/api/auth" apiauth "github.com/harness/gitness/app/api/auth"
"github.com/harness/gitness/app/api/controller" "github.com/harness/gitness/app/api/controller"
"github.com/harness/gitness/app/api/usererror"
"github.com/harness/gitness/app/auth" "github.com/harness/gitness/app/auth"
repoevents "github.com/harness/gitness/app/events/repo" repoevents "github.com/harness/gitness/app/events/repo"
"github.com/harness/gitness/errors" "github.com/harness/gitness/errors"
@ -30,12 +31,11 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
// Delete deletes a repo. // Purge removes a repo permanently.
func (c *Controller) Delete(ctx context.Context, session *auth.Session, repoRef string) error { func (c *Controller) Purge(ctx context.Context, session *auth.Session, repoRef string, deletedAt int64) error {
// note: can't use c.getRepoCheckAccess because import job for repositories being imported must be cancelled. repo, err := c.repoStore.FindByRefAndDeletedAt(ctx, repoRef, deletedAt)
repo, err := c.repoStore.FindByRef(ctx, repoRef)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to find the repo (deleted at %d): %w", deletedAt, err)
} }
if err = apiauth.CheckRepo(ctx, c.authorizer, session, repo, enum.PermissionRepoDelete, false); err != nil { if err = apiauth.CheckRepo(ctx, c.authorizer, session, repo, enum.PermissionRepoDelete, false); err != nil {
@ -45,25 +45,26 @@ func (c *Controller) Delete(ctx context.Context, session *auth.Session, repoRef
log.Ctx(ctx).Info(). log.Ctx(ctx).Info().
Int64("repo.id", repo.ID). Int64("repo.id", repo.ID).
Str("repo.path", repo.Path). Str("repo.path", repo.Path).
Msgf("deleting repository") Msg("purging repository")
if repo.Importing { if repo.Deleted == nil {
err = c.importer.Cancel(ctx, repo) return usererror.BadRequest("Repository has to be deleted before it can be purged.")
if err != nil {
return fmt.Errorf("failed to cancel repository import")
}
} }
return c.DeleteNoAuth(ctx, session, repo) return c.PurgeNoAuth(ctx, session, repo)
} }
func (c *Controller) DeleteNoAuth(ctx context.Context, session *auth.Session, repo *types.Repository) error { func (c *Controller) PurgeNoAuth(
if err := c.deleteGitRepository(ctx, session, repo); err != nil { ctx context.Context,
return fmt.Errorf("failed to delete git repository: %w", err) session *auth.Session,
repo *types.Repository,
) error {
if err := c.repoStore.Purge(ctx, repo.ID, repo.Deleted); err != nil {
return fmt.Errorf("failed to delete repo from db: %w", err)
} }
if err := c.repoStore.Delete(ctx, repo.ID); err != nil { if err := c.deleteGitRepository(ctx, session, repo); err != nil {
return fmt.Errorf("failed to delete repo from db: %w", err) return fmt.Errorf("failed to delete git repository: %w", err)
} }
c.eventReporter.Deleted( c.eventReporter.Deleted(
@ -95,13 +96,12 @@ func (c *Controller) deleteGitRepository(
WriteParams: writeParams, WriteParams: writeParams,
}) })
// deletion should not fail if dir does not exist in repos dir // deletion should not fail if repo dir does not exist.
if errors.IsNotFound(err) { if errors.IsNotFound(err) {
log.Ctx(ctx).Warn().Str("repo.git_uid", repo.GitUID). log.Ctx(ctx).Warn().Str("repo.git_uid", repo.GitUID).
Msg("git repository directory does not exist") Msg("git repository directory does not exist")
} else if err != nil { } else if err != nil {
// deletion has failed before removing(rename) the repo dir return fmt.Errorf("failed to remove git repository %s: %w", repo.GitUID, err)
return fmt.Errorf("failed to delete git repository directory %s: %w", repo.GitUID, err)
} }
return nil return nil
} }

View File

@ -0,0 +1,58 @@
// 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 repo
import (
"context"
"fmt"
apiauth "github.com/harness/gitness/app/api/auth"
"github.com/harness/gitness/app/api/usererror"
"github.com/harness/gitness/app/auth"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
)
type RestoreInput struct {
NewIdentifier string `json:"new_identifier,omitempty"`
DeletedAt int64 `json:"deleted_at"`
}
func (c *Controller) Restore(
ctx context.Context,
session *auth.Session,
repoRef string,
in *RestoreInput,
) (*types.Repository, error) {
repo, err := c.repoStore.FindByRefAndDeletedAt(ctx, repoRef, in.DeletedAt)
if err != nil {
return nil, fmt.Errorf("failed to find repository: %w", err)
}
if err = apiauth.CheckRepo(ctx, c.authorizer, session, repo, enum.PermissionRepoEdit, false); err != nil {
return nil, fmt.Errorf("access check failed: %w", err)
}
if repo.Deleted == nil {
return nil, usererror.BadRequest("cannot restore a repo that hasn't been deleted")
}
repo, err = c.repoStore.Restore(ctx, repo, in.NewIdentifier)
if err != nil {
return nil, fmt.Errorf("failed to restore the repo: %w", err)
}
return repo, nil
}

View File

@ -0,0 +1,75 @@
// 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 repo
import (
"context"
"fmt"
"time"
apiauth "github.com/harness/gitness/app/api/auth"
"github.com/harness/gitness/app/api/usererror"
"github.com/harness/gitness/app/auth"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
"github.com/rs/zerolog/log"
)
// SoftDelete soft deletes a repo (aka sets the deleted timestamp while keep the data).
func (c *Controller) SoftDelete(ctx context.Context, session *auth.Session, repoRef string) error {
// note: can't use c.getRepoCheckAccess because import job for repositories being imported must be cancelled.
repo, err := c.repoStore.FindByRef(ctx, repoRef)
if err != nil {
return fmt.Errorf("failed to find the repo for soft delete: %w", err)
}
if err = apiauth.CheckRepo(ctx, c.authorizer, session, repo, enum.PermissionRepoDelete, false); err != nil {
return fmt.Errorf("access check failed: %w", err)
}
log.Ctx(ctx).Info().
Int64("repo.id", repo.ID).
Str("repo.path", repo.Path).
Msg("soft deleting repository")
if repo.Deleted != nil {
return usererror.BadRequest("repository has been already deleted")
}
if repo.Importing {
log.Ctx(ctx).Info().Msg("repository is importing. cancelling the import job and purge the repo.")
err = c.importer.Cancel(ctx, repo)
if err != nil {
return fmt.Errorf("failed to cancel repository import")
}
return c.PurgeNoAuth(ctx, session, repo)
}
return c.SoftDeleteNoAuth(ctx, repo, time.Now().UnixMilli())
}
func (c *Controller) SoftDeleteNoAuth(
ctx context.Context,
repo *types.Repository,
deletedAt int64,
) error {
err := c.repoStore.SoftDelete(ctx, repo, &deletedAt)
if err != nil {
return fmt.Errorf("failed to soft delete repo from db: %w", err)
}
return nil
}

View File

@ -18,6 +18,7 @@ import (
"context" "context"
"fmt" "fmt"
"math" "math"
"time"
apiauth "github.com/harness/gitness/app/api/auth" apiauth "github.com/harness/gitness/app/api/auth"
"github.com/harness/gitness/app/auth" "github.com/harness/gitness/app/auth"
@ -73,18 +74,29 @@ func (c *Controller) DeleteNoAuth(ctx context.Context, session *auth.Session, sp
// WARNING this is meant for internal calls only. // WARNING this is meant for internal calls only.
func (c *Controller) deleteRepositoriesNoAuth(ctx context.Context, session *auth.Session, spaceID int64) error { func (c *Controller) deleteRepositoriesNoAuth(ctx context.Context, session *auth.Session, spaceID int64) error {
filter := &types.RepoFilter{ filter := &types.RepoFilter{
Page: 1, Page: 1,
Size: int(math.MaxInt), Size: int(math.MaxInt),
Query: "", Query: "",
Order: enum.OrderAsc, Order: enum.OrderAsc,
Sort: enum.RepoAttrNone, Sort: enum.RepoAttrNone,
DeletedBefore: nil,
} }
repos, _, err := c.ListRepositoriesNoAuth(ctx, spaceID, filter) repos, _, err := c.ListRepositoriesNoAuth(ctx, spaceID, filter)
if err != nil { if err != nil {
return fmt.Errorf("failed to list space repositories: %w", err) return fmt.Errorf("failed to list space repositories: %w", err)
} }
// TEMPORARY until we support space delete/restore CODE-1413
recent := time.Now().Add(+time.Hour * 24).UnixMilli()
filter.DeletedBefore = &recent
alreadyDeletedRepos, _, err := c.ListRepositoriesNoAuth(ctx, spaceID, filter)
if err != nil {
return fmt.Errorf("failed to list delete repositories for space %d: %w", spaceID, err)
}
repos = append(repos, alreadyDeletedRepos...)
for _, repo := range repos { for _, repo := range repos {
err = c.repoCtrl.DeleteNoAuth(ctx, session, repo) err = c.repoCtrl.PurgeNoAuth(ctx, session, repo)
if err != nil { if err != nil {
return fmt.Errorf("failed to delete repository %d: %w", repo.ID, err) return fmt.Errorf("failed to delete repository %d: %w", repo.ID, err)
} }

View File

@ -0,0 +1,51 @@
// 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 repo
import (
"net/http"
"github.com/harness/gitness/app/api/controller/repo"
"github.com/harness/gitness/app/api/render"
"github.com/harness/gitness/app/api/request"
)
func HandlePurge(repoCtrl *repo.Controller) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
session, _ := request.AuthSessionFrom(ctx)
repoRef, err := request.GetRepoRefFromPath(r)
if err != nil {
render.TranslatedUserError(w, err)
return
}
deletedAt, err := request.GetRepoDeletedAtFromQuery(r)
if err != nil {
render.TranslatedUserError(w, err)
return
}
err = repoCtrl.Purge(ctx, session, repoRef, deletedAt)
if err != nil {
render.TranslatedUserError(w, err)
return
}
render.DeleteSuccessful(w)
}
}

View File

@ -0,0 +1,53 @@
// 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 repo
import (
"encoding/json"
"net/http"
"github.com/harness/gitness/app/api/controller/repo"
"github.com/harness/gitness/app/api/render"
"github.com/harness/gitness/app/api/request"
)
func HandleRestore(repoCtrl *repo.Controller) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
session, _ := request.AuthSessionFrom(ctx)
repoRef, err := request.GetRepoRefFromPath(r)
if err != nil {
render.TranslatedUserError(w, err)
return
}
in := new(repo.RestoreInput)
err = json.NewDecoder(r.Body).Decode(in)
if err != nil {
render.BadRequestf(w, "Invalid request body: %s.", err)
return
}
repo, err := repoCtrl.Restore(ctx, session, repoRef, in)
if err != nil {
render.TranslatedUserError(w, err)
return
}
render.JSON(w, http.StatusOK, repo)
}
}

View File

@ -23,19 +23,21 @@ import (
) )
/* /*
* Deletes a repository. * Soft Deletes a repository.
*/ */
func HandleDelete(repoCtrl *repo.Controller) http.HandlerFunc { func HandleSoftDelete(repoCtrl *repo.Controller) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
session, _ := request.AuthSessionFrom(ctx) session, _ := request.AuthSessionFrom(ctx)
repoRef, err := request.GetRepoRefFromPath(r) repoRef, err := request.GetRepoRefFromPath(r)
if err != nil { if err != nil {
render.TranslatedUserError(w, err) render.TranslatedUserError(w, err)
return return
} }
err = repoCtrl.Delete(ctx, session, repoRef) err = repoCtrl.SoftDelete(ctx, session, repoRef)
if err != nil { if err != nil {
render.TranslatedUserError(w, err) render.TranslatedUserError(w, err)
return return

View File

@ -194,6 +194,11 @@ type rule struct {
Pattern protection.Pattern `json:"pattern"` Pattern protection.Pattern `json:"pattern"`
} }
type restoreRequest struct {
repoRequest
repo.RestoreInput
}
var queryParameterGitRef = openapi3.ParameterOrRef{ var queryParameterGitRef = openapi3.ParameterOrRef{
Parameter: &openapi3.Parameter{ Parameter: &openapi3.Parameter{
Name: request.QueryParamGitRef, Name: request.QueryParamGitRef,
@ -453,6 +458,20 @@ var queryParameterBypassRules = openapi3.ParameterOrRef{
}, },
} }
var queryParameterRepoDeletedAt = openapi3.ParameterOrRef{
Parameter: &openapi3.Parameter{
Name: request.QueryParamRepoDeletedAt,
In: openapi3.ParameterInQuery,
Description: ptr.String("The time repository was deleted at in epoch format."),
Required: ptr.Bool(true),
Schema: &openapi3.SchemaOrRef{
Schema: &openapi3.Schema{
Type: ptrSchemaType(openapi3.SchemaTypeInteger),
},
},
},
}
//nolint:funlen //nolint:funlen
func repoOperations(reflector *openapi3.Reflector) { func repoOperations(reflector *openapi3.Reflector) {
createRepository := openapi3.Operation{} createRepository := openapi3.Operation{}
@ -513,6 +532,30 @@ func repoOperations(reflector *openapi3.Reflector) {
_ = reflector.SetJSONResponse(&opDelete, new(usererror.Error), http.StatusNotFound) _ = reflector.SetJSONResponse(&opDelete, new(usererror.Error), http.StatusNotFound)
_ = reflector.Spec.AddOperation(http.MethodDelete, "/repos/{repo_ref}", opDelete) _ = reflector.Spec.AddOperation(http.MethodDelete, "/repos/{repo_ref}", opDelete)
opPurge := openapi3.Operation{}
opPurge.WithTags("repository")
opPurge.WithMapOfAnything(map[string]interface{}{"operationId": "purgeRepository"})
opPurge.WithParameters(queryParameterRepoDeletedAt)
_ = reflector.SetRequest(&opPurge, new(repoRequest), http.MethodPost)
_ = reflector.SetJSONResponse(&opPurge, nil, http.StatusNoContent)
_ = reflector.SetJSONResponse(&opPurge, new(usererror.Error), http.StatusInternalServerError)
_ = reflector.SetJSONResponse(&opPurge, new(usererror.Error), http.StatusUnauthorized)
_ = reflector.SetJSONResponse(&opPurge, new(usererror.Error), http.StatusForbidden)
_ = reflector.SetJSONResponse(&opPurge, new(usererror.Error), http.StatusNotFound)
_ = reflector.Spec.AddOperation(http.MethodPost, "/repos/{repo_ref}/purge", opPurge)
opRestore := openapi3.Operation{}
opRestore.WithTags("repository")
opRestore.WithMapOfAnything(map[string]interface{}{"operationId": "restoreRepository"})
_ = reflector.SetRequest(&opRestore, new(restoreRequest), http.MethodPost)
_ = reflector.SetJSONResponse(&opRestore, new(types.Repository), http.StatusOK)
_ = reflector.SetJSONResponse(&opRestore, new(usererror.Error), http.StatusBadRequest)
_ = reflector.SetJSONResponse(&opRestore, new(usererror.Error), http.StatusInternalServerError)
_ = reflector.SetJSONResponse(&opRestore, new(usererror.Error), http.StatusUnauthorized)
_ = reflector.SetJSONResponse(&opRestore, new(usererror.Error), http.StatusForbidden)
_ = reflector.SetJSONResponse(&opRestore, new(usererror.Error), http.StatusNotFound)
_ = reflector.Spec.AddOperation(http.MethodPost, "/repos/{repo_ref}/restore", opRestore)
opMove := openapi3.Operation{} opMove := openapi3.Operation{}
opMove.WithTags("repository") opMove.WithTags("repository")
opMove.WithMapOfAnything(map[string]interface{}{"operationId": "moveRepository"}) opMove.WithMapOfAnything(map[string]interface{}{"operationId": "moveRepository"})

View File

@ -23,8 +23,9 @@ import (
) )
const ( const (
PathParamRepoRef = "repo_ref" PathParamRepoRef = "repo_ref"
QueryParamRepoID = "repo_id" QueryParamRepoID = "repo_id"
QueryParamRepoDeletedAt = "repo_deleted_at"
) )
func GetRepoRefFromPath(r *http.Request) (string, error) { func GetRepoRefFromPath(r *http.Request) (string, error) {
@ -54,3 +55,8 @@ func ParseRepoFilter(r *http.Request) *types.RepoFilter {
Size: ParseLimit(r), Size: ParseLimit(r),
} }
} }
// GetRepoDeletedAtFromQuery extracts the repository deleted timestamp from the query.
func GetRepoDeletedAtFromQuery(r *http.Request) (int64, error) {
return QueryParamAsPositiveInt64(r, QueryParamRepoDeletedAt)
}

View File

@ -260,7 +260,9 @@ func setupRepos(r chi.Router,
// repo level operations // repo level operations
r.Get("/", handlerrepo.HandleFind(repoCtrl)) r.Get("/", handlerrepo.HandleFind(repoCtrl))
r.Patch("/", handlerrepo.HandleUpdate(repoCtrl)) r.Patch("/", handlerrepo.HandleUpdate(repoCtrl))
r.Delete("/", handlerrepo.HandleDelete(repoCtrl)) r.Delete("/", handlerrepo.HandleSoftDelete(repoCtrl))
r.Post("/purge", handlerrepo.HandlePurge(repoCtrl))
r.Post("/restore", handlerrepo.HandleRestore(repoCtrl))
r.Post("/move", handlerrepo.HandleMove(repoCtrl)) r.Post("/move", handlerrepo.HandleMove(repoCtrl))
r.Get("/service-accounts", handlerrepo.HandleListServiceAccounts(repoCtrl)) r.Get("/service-accounts", handlerrepo.HandleListServiceAccounts(repoCtrl))

View File

@ -0,0 +1,102 @@
// 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 cleanup
import (
"context"
"fmt"
"math"
"time"
"github.com/harness/gitness/app/api/controller/repo"
"github.com/harness/gitness/app/bootstrap"
"github.com/harness/gitness/app/store"
"github.com/harness/gitness/job"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
"github.com/rs/zerolog/log"
)
const (
jobTypeDeletedRepos = "gitness:cleanup:deleted-repos"
jobCronDeletedRepos = "50 0 * * *" // At minute 50 past midnight every day.
jobMaxDurationDeletedRepos = 10 * time.Minute
)
type deletedReposCleanupJob struct {
retentionTime time.Duration
repoStore store.RepoStore
repoCtrl *repo.Controller
}
func newDeletedReposCleanupJob(
retentionTime time.Duration,
repoStore store.RepoStore,
repoCtrl *repo.Controller,
) *deletedReposCleanupJob {
return &deletedReposCleanupJob{
retentionTime: retentionTime,
repoStore: repoStore,
repoCtrl: repoCtrl,
}
}
// Handle purges old deleted repositories that are past the retention time.
func (j *deletedReposCleanupJob) Handle(ctx context.Context, _ string, _ job.ProgressReporter) (string, error) {
olderThan := time.Now().Add(-j.retentionTime)
log.Ctx(ctx).Info().Msgf(
"start purging deleted repositories older than %s (aka created before %s)",
j.retentionTime,
olderThan.Format(time.RFC3339Nano))
deletedBefore := olderThan.UnixMilli()
filter := &types.RepoFilter{
Page: 1,
Size: int(math.MaxInt),
Query: "",
Order: enum.OrderAsc,
Sort: enum.RepoAttrDeleted,
DeletedBefore: &deletedBefore,
}
toBePurgedRepos, err := j.repoStore.List(ctx, 0, filter)
if err != nil {
return "", fmt.Errorf("failed to list ready-to-delete repositories: %w", err)
}
session := bootstrap.NewSystemServiceSession()
purgedRepos := 0
for _, r := range toBePurgedRepos {
err := j.repoCtrl.PurgeNoAuth(ctx, session, r)
if err != nil {
log.Warn().Err(err).Msgf("failed to purge repo uid: %s, path: %s, deleted at %d",
r.Identifier, r.Path, *r.Deleted)
continue
}
purgedRepos++
}
result := "no old deleted repositories found"
if purgedRepos > 0 {
result = fmt.Sprintf("purged %d deleted repositories", purgedRepos)
}
log.Ctx(ctx).Info().Msg(result)
return result, nil
}

View File

@ -20,12 +20,14 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/harness/gitness/app/api/controller/repo"
"github.com/harness/gitness/app/store" "github.com/harness/gitness/app/store"
"github.com/harness/gitness/job" "github.com/harness/gitness/job"
) )
type Config struct { type Config struct {
WebhookExecutionsRetentionTime time.Duration WebhookExecutionsRetentionTime time.Duration
DeletedRepositoriesRetentionTime time.Duration
} }
func (c *Config) Prepare() error { func (c *Config) Prepare() error {
@ -35,6 +37,10 @@ func (c *Config) Prepare() error {
if c.WebhookExecutionsRetentionTime <= 0 { if c.WebhookExecutionsRetentionTime <= 0 {
return errors.New("config.WebhookExecutionsRetentionTime has to be provided") return errors.New("config.WebhookExecutionsRetentionTime has to be provided")
} }
if c.DeletedRepositoriesRetentionTime <= 0 {
return errors.New("config.DeletedRepositoriesRetentionTime has to be provided")
}
return nil return nil
} }
@ -45,6 +51,8 @@ type Service struct {
executor *job.Executor executor *job.Executor
webhookExecutionStore store.WebhookExecutionStore webhookExecutionStore store.WebhookExecutionStore
tokenStore store.TokenStore tokenStore store.TokenStore
repoStore store.RepoStore
repoCtrl *repo.Controller
} }
func NewService( func NewService(
@ -53,6 +61,8 @@ func NewService(
executor *job.Executor, executor *job.Executor,
webhookExecutionStore store.WebhookExecutionStore, webhookExecutionStore store.WebhookExecutionStore,
tokenStore store.TokenStore, tokenStore store.TokenStore,
repoStore store.RepoStore,
repoCtrl *repo.Controller,
) (*Service, error) { ) (*Service, error) {
if err := config.Prepare(); err != nil { if err := config.Prepare(); err != nil {
return nil, fmt.Errorf("provided cleanup config is invalid: %w", err) return nil, fmt.Errorf("provided cleanup config is invalid: %w", err)
@ -65,6 +75,8 @@ func NewService(
executor: executor, executor: executor,
webhookExecutionStore: webhookExecutionStore, webhookExecutionStore: webhookExecutionStore,
tokenStore: tokenStore, tokenStore: tokenStore,
repoStore: repoStore,
repoCtrl: repoCtrl,
}, nil }, nil
} }
@ -104,6 +116,16 @@ func (s *Service) scheduleRecurringCleanupJobs(ctx context.Context) error {
return fmt.Errorf("failed to schedule token job: %w", err) return fmt.Errorf("failed to schedule token job: %w", err)
} }
err = s.scheduler.AddRecurring(
ctx,
jobTypeDeletedRepos,
jobTypeDeletedRepos,
jobCronDeletedRepos,
jobMaxDurationDeletedRepos,
)
if err != nil {
return fmt.Errorf("failed to schedule deleted repo cleanup job: %w", err)
}
return nil return nil
} }
@ -128,5 +150,15 @@ func (s *Service) registerJobHandlers() error {
return fmt.Errorf("failed to register job handler for token cleanup: %w", err) return fmt.Errorf("failed to register job handler for token cleanup: %w", err)
} }
if err := s.executor.Register(
jobTypeDeletedRepos,
newDeletedReposCleanupJob(
s.config.DeletedRepositoriesRetentionTime,
s.repoStore,
s.repoCtrl,
),
); err != nil {
return fmt.Errorf("failed to register job handler for deleted repos cleanup: %w", err)
}
return nil return nil
} }

View File

@ -15,6 +15,7 @@
package cleanup package cleanup
import ( import (
"github.com/harness/gitness/app/api/controller/repo"
"github.com/harness/gitness/app/store" "github.com/harness/gitness/app/store"
"github.com/harness/gitness/job" "github.com/harness/gitness/job"
@ -31,6 +32,8 @@ func ProvideService(
executor *job.Executor, executor *job.Executor,
webhookExecutionStore store.WebhookExecutionStore, webhookExecutionStore store.WebhookExecutionStore,
tokenStore store.TokenStore, tokenStore store.TokenStore,
repoStore store.RepoStore,
repoCtrl *repo.Controller,
) (*Service, error) { ) (*Service, error) {
return NewService( return NewService(
config, config,
@ -38,5 +41,7 @@ func ProvideService(
executor, executor,
webhookExecutionStore, webhookExecutionStore,
tokenStore, tokenStore,
repoStore,
repoCtrl,
) )
} }

View File

@ -184,6 +184,9 @@ type (
// Find the repo by id. // Find the repo by id.
Find(ctx context.Context, id int64) (*types.Repository, error) Find(ctx context.Context, id int64) (*types.Repository, error)
// FindByRefAndDeletedAt finds the repo using the repoRef and deleted timestamp.
FindByRefAndDeletedAt(ctx context.Context, repoRef string, deletedAt int64) (*types.Repository, error)
// FindByRef finds the repo using the repoRef as either the id or the repo path. // FindByRef finds the repo using the repoRef as either the id or the repo path.
FindByRef(ctx context.Context, repoRef string) (*types.Repository, error) FindByRef(ctx context.Context, repoRef string) (*types.Repository, error)
@ -194,28 +197,36 @@ type (
Update(ctx context.Context, repo *types.Repository) error Update(ctx context.Context, repo *types.Repository) error
// Update the repo size. // Update the repo size.
UpdateSize(ctx context.Context, repoID int64, repoSize int64) error UpdateSize(ctx context.Context, id int64, repoSize int64) error
// Get the repo size. // Get the repo size.
GetSize(ctx context.Context, repoID int64) (int64, error) GetSize(ctx context.Context, id int64) (int64, error)
// UpdateOptLock the repo details using the optimistic locking mechanism. // UpdateOptLock the repo details using the optimistic locking mechanism.
UpdateOptLock(ctx context.Context, repo *types.Repository, UpdateOptLock(ctx context.Context, repo *types.Repository,
mutateFn func(repository *types.Repository) error) (*types.Repository, error) mutateFn func(repository *types.Repository) error) (*types.Repository, error)
// Delete the repo. // SoftDelete a repo.
Delete(ctx context.Context, id int64) error SoftDelete(ctx context.Context, repo *types.Repository, deletedAt *int64) error
// Count of repos in a space. // Purge the soft deleted repo permanently.
Purge(ctx context.Context, id int64, deletedAt *int64) error
// Restore a deleted repo using the optimistic locking mechanism.
Restore(ctx context.Context, repo *types.Repository,
newIdentifier string) (*types.Repository, error)
// Count of active repos in a space. With "DeletedBefore" filter, counts only deleted repos by opts.DeletedBefore.
Count(ctx context.Context, parentID int64, opts *types.RepoFilter) (int64, error) Count(ctx context.Context, parentID int64, opts *types.RepoFilter) (int64, error)
// Count all repos in a hierarchy of spaces. // Count all active repos in a hierarchy of spaces.
CountAll(ctx context.Context, spaceID int64) (int64, error) CountAll(ctx context.Context, spaceID int64) (int64, error)
// List returns a list of repos in a space. // List returns a list of active repos in a space.
// With "DeletedBefore" filter, shows deleted repos by opts.DeletedBefore.
List(ctx context.Context, parentID int64, opts *types.RepoFilter) ([]*types.Repository, error) List(ctx context.Context, parentID int64, opts *types.RepoFilter) ([]*types.Repository, error)
// ListSizeInfos returns a list of all repo sizes. // ListSizeInfos returns a list of all active repo sizes.
ListSizeInfos(ctx context.Context) ([]*types.RepositorySizeInfo, error) ListSizeInfos(ctx context.Context) ([]*types.RepositorySizeInfo, error)
} }

View File

@ -0,0 +1,7 @@
ALTER TABLE repositories DROP COLUMN repo_deleted;
DROP INDEX repositories_parent_id_uid;
DROP INDEX repositories_deleted;
CREATE UNIQUE INDEX repositories_parent_id_uid
ON repositories(repo_parent_id, LOWER(repo_uid));

View File

@ -0,0 +1,11 @@
ALTER TABLE repositories ADD COLUMN repo_deleted BIGINT DEFAULT NULL;
DROP INDEX repositories_parent_id_uid;
CREATE UNIQUE INDEX repositories_parent_id_uid
ON repositories(repo_parent_id, LOWER(repo_uid));
WHERE repo_deleted IS NULL;
CREATE INDEX repositories_deleted
ON repositories(repo_deleted)
WHERE repo_deleted IS NOT NULL;

View File

@ -0,0 +1,7 @@
ALTER TABLE repositories DROP COLUMN repo_deleted;
DROP INDEX repositories_parent_id_uid;
DROP INDEX repositories_deleted;
CREATE UNIQUE INDEX repositories_parent_id_uid
ON repositories(repo_parent_id, LOWER(repo_uid));

View File

@ -0,0 +1,11 @@
ALTER TABLE repositories ADD COLUMN repo_deleted BIGINT DEFAULT NULL;
DROP INDEX repositories_parent_id_uid;
CREATE UNIQUE INDEX repositories_parent_id_uid
ON repositories(repo_parent_id, LOWER(repo_uid))
WHERE repo_deleted IS NULL;
CREATE INDEX repositories_deleted
ON repositories(repo_deleted)
WHERE repo_deleted IS NOT NULL;

View File

@ -29,6 +29,7 @@ import (
"github.com/harness/gitness/types" "github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum" "github.com/harness/gitness/types/enum"
"github.com/guregu/null"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@ -57,15 +58,16 @@ type RepoStore struct {
type repository struct { type repository struct {
// TODO: int64 ID doesn't match DB // TODO: int64 ID doesn't match DB
ID int64 `db:"repo_id"` ID int64 `db:"repo_id"`
Version int64 `db:"repo_version"` Version int64 `db:"repo_version"`
ParentID int64 `db:"repo_parent_id"` ParentID int64 `db:"repo_parent_id"`
Identifier string `db:"repo_uid"` Identifier string `db:"repo_uid"`
Description string `db:"repo_description"` Description string `db:"repo_description"`
IsPublic bool `db:"repo_is_public"` IsPublic bool `db:"repo_is_public"`
CreatedBy int64 `db:"repo_created_by"` CreatedBy int64 `db:"repo_created_by"`
Created int64 `db:"repo_created"` Created int64 `db:"repo_created"`
Updated int64 `db:"repo_updated"` Updated int64 `db:"repo_updated"`
Deleted null.Int `db:"repo_deleted"`
Size int64 `db:"repo_size"` Size int64 `db:"repo_size"`
SizeUpdated int64 `db:"repo_size_updated"` SizeUpdated int64 `db:"repo_size_updated"`
@ -95,6 +97,7 @@ const (
,repo_created_by ,repo_created_by
,repo_created ,repo_created
,repo_updated ,repo_updated
,repo_deleted
,repo_size ,repo_size
,repo_size_updated ,repo_size_updated
,repo_git_uid ,repo_git_uid
@ -115,40 +118,53 @@ const (
// Find finds the repo by id. // Find finds the repo by id.
func (s *RepoStore) Find(ctx context.Context, id int64) (*types.Repository, error) { func (s *RepoStore) Find(ctx context.Context, id int64) (*types.Repository, error) {
const sqlQuery = repoSelectBase + ` return s.find(ctx, id, nil)
WHERE repo_id = $1` }
// find is a wrapper to find a repo by id w/o deleted timestamp.
func (s *RepoStore) find(ctx context.Context, id int64, deletedAt *int64) (*types.Repository, error) {
var sqlQuery = repoSelectBase + `
WHERE repo_id = $1 AND repo_deleted IS NULL`
if deletedAt != nil {
sqlQuery = repoSelectBase + `
WHERE repo_id = $1 AND repo_deleted = $2`
}
db := dbtx.GetAccessor(ctx, s.db) db := dbtx.GetAccessor(ctx, s.db)
dst := new(repository) dst := new(repository)
if err := db.GetContext(ctx, dst, sqlQuery, id); err != nil { if err := db.GetContext(ctx, dst, sqlQuery, id, deletedAt); err != nil {
return nil, database.ProcessSQLErrorf(err, "Failed to find repo") return nil, database.ProcessSQLErrorf(err, "Failed to find repo")
} }
return s.mapToRepo(ctx, dst) return s.mapToRepo(ctx, dst)
} }
// Find finds the repo with the given identifier in the given space ID. func (s *RepoStore) findByIdentifier(
func (s *RepoStore) FindByIdentifier(
ctx context.Context, ctx context.Context,
spaceID int64, spaceID int64,
identifier string, identifier string,
deletedAt *int64,
) (*types.Repository, error) { ) (*types.Repository, error) {
const sqlQuery = repoSelectBase + ` var sqlQuery = repoSelectBase + `
WHERE repo_parent_id = $1 AND LOWER(repo_uid) = $2` WHERE repo_parent_id = $1 AND LOWER(repo_uid) = $2 AND repo_deleted IS NULL`
if deletedAt != nil {
sqlQuery = repoSelectBase + `
WHERE repo_parent_id = $1 AND LOWER(repo_uid) = $2 AND repo_deleted = $3`
}
db := dbtx.GetAccessor(ctx, s.db) db := dbtx.GetAccessor(ctx, s.db)
dst := new(repository) dst := new(repository)
if err := db.GetContext(ctx, dst, sqlQuery, spaceID, strings.ToLower(identifier)); err != nil { if err := db.GetContext(ctx, dst, sqlQuery, spaceID, strings.ToLower(identifier), deletedAt); err != nil {
return nil, database.ProcessSQLErrorf(err, "Failed to find repo") return nil, database.ProcessSQLErrorf(err, "Failed to find repo")
} }
return s.mapToRepo(ctx, dst) return s.mapToRepo(ctx, dst)
} }
// FindByRef finds the repo using the repoRef as either the id or the repo path. func (s *RepoStore) findByRef(ctx context.Context, repoRef string, deletedAt *int64) (*types.Repository, error) {
func (s *RepoStore) FindByRef(ctx context.Context, repoRef string) (*types.Repository, error) {
// ASSUMPTION: digits only is not a valid repo path // ASSUMPTION: digits only is not a valid repo path
id, err := strconv.ParseInt(repoRef, 10, 64) id, err := strconv.ParseInt(repoRef, 10, 64)
if err != nil { if err != nil {
@ -161,10 +177,23 @@ func (s *RepoStore) FindByRef(ctx context.Context, repoRef string) (*types.Repos
return nil, fmt.Errorf("failed to get space path: %w", err) return nil, fmt.Errorf("failed to get space path: %w", err)
} }
return s.FindByIdentifier(ctx, pathObject.SpaceID, repoIdentifier) return s.findByIdentifier(ctx, pathObject.SpaceID, repoIdentifier, deletedAt)
} }
return s.find(ctx, id, deletedAt)
}
return s.Find(ctx, id) // FindByRef finds the repo using the repoRef as either the id or the repo path.
func (s *RepoStore) FindByRef(ctx context.Context, repoRef string) (*types.Repository, error) {
return s.findByRef(ctx, repoRef, nil)
}
// FindByRefAndDeletedAt finds the repo using the repoRef and deleted timestamp.
func (s *RepoStore) FindByRefAndDeletedAt(
ctx context.Context,
repoRef string,
deletedAt int64,
) (*types.Repository, error) {
return s.findByRef(ctx, repoRef, &deletedAt)
} }
// Create creates a new repository. // Create creates a new repository.
@ -179,6 +208,7 @@ func (s *RepoStore) Create(ctx context.Context, repo *types.Repository) error {
,repo_created_by ,repo_created_by
,repo_created ,repo_created
,repo_updated ,repo_updated
,repo_deleted
,repo_size ,repo_size
,repo_size_updated ,repo_size_updated
,repo_git_uid ,repo_git_uid
@ -200,6 +230,7 @@ func (s *RepoStore) Create(ctx context.Context, repo *types.Repository) error {
,:repo_created_by ,:repo_created_by
,:repo_created ,:repo_created
,:repo_updated ,:repo_updated
,:repo_deleted
,:repo_size ,:repo_size
,:repo_size_updated ,:repo_size_updated
,:repo_git_uid ,:repo_git_uid
@ -241,6 +272,7 @@ func (s *RepoStore) Update(ctx context.Context, repo *types.Repository) error {
SET SET
repo_version = :repo_version repo_version = :repo_version
,repo_updated = :repo_updated ,repo_updated = :repo_updated
,repo_deleted = :repo_deleted
,repo_parent_id = :repo_parent_id ,repo_parent_id = :repo_parent_id
,repo_uid = :repo_uid ,repo_uid = :repo_uid
,repo_git_uid = :repo_git_uid ,repo_git_uid = :repo_git_uid
@ -296,12 +328,12 @@ func (s *RepoStore) Update(ctx context.Context, repo *types.Repository) error {
} }
// UpdateSize updates the size of a specific repository in the database. // UpdateSize updates the size of a specific repository in the database.
func (s *RepoStore) UpdateSize(ctx context.Context, repoID int64, size int64) error { func (s *RepoStore) UpdateSize(ctx context.Context, id int64, size int64) error {
stmt := database.Builder. stmt := database.Builder.
Update("repositories"). Update("repositories").
Set("repo_size", size). Set("repo_size", size).
Set("repo_size_updated", time.Now().UnixMilli()). Set("repo_size_updated", time.Now().UnixMilli()).
Where("repo_id = ?", repoID) Where("repo_id = ? AND repo_deleted IS NULL", id)
sqlQuery, args, err := stmt.ToSql() sqlQuery, args, err := stmt.ToSql()
if err != nil { if err != nil {
@ -321,26 +353,62 @@ func (s *RepoStore) UpdateSize(ctx context.Context, repoID int64, size int64) er
} }
if count == 0 { if count == 0 {
return fmt.Errorf("repo %d size not updated: %w", repoID, gitness_store.ErrResourceNotFound) return fmt.Errorf("repo %d size not updated: %w", id, gitness_store.ErrResourceNotFound)
} }
return nil return nil
} }
// GetSize returns the repo size. // GetSize returns the repo size.
func (s *RepoStore) GetSize(ctx context.Context, repoID int64) (int64, error) { func (s *RepoStore) GetSize(ctx context.Context, id int64) (int64, error) {
query := "SELECT repo_size FROM repositories WHERE repo_id = $1;" query := "SELECT repo_size FROM repositories WHERE repo_id = $1 AND repo_deleted IS NULL;"
db := dbtx.GetAccessor(ctx, s.db) db := dbtx.GetAccessor(ctx, s.db)
var size int64 var size int64
if err := db.GetContext(ctx, &size, query, repoID); err != nil { if err := db.GetContext(ctx, &size, query, id); err != nil {
return 0, database.ProcessSQLErrorf(err, "failed to get repo size") return 0, database.ProcessSQLErrorf(err, "failed to get repo size")
} }
return size, nil return size, nil
} }
// UpdateOptLock updates the repository using the optimistic locking mechanism. // UpdateOptLock updates the active repository using the optimistic locking mechanism.
func (s *RepoStore) UpdateOptLock(ctx context.Context, func (s *RepoStore) UpdateOptLock(
ctx context.Context,
repo *types.Repository,
mutateFn func(repository *types.Repository) error,
) (*types.Repository, error) {
return s.updateOptLock(
ctx,
repo,
func(r *types.Repository) error {
if repo.Deleted != nil {
return gitness_store.ErrResourceNotFound
}
return mutateFn(r)
},
)
}
// UpdateDeletedOptLock updates a deleted repository using the optimistic locking mechanism.
func (s *RepoStore) updateDeletedOptLock(ctx context.Context,
repo *types.Repository,
mutateFn func(repository *types.Repository) error,
) (*types.Repository, error) {
return s.updateOptLock(
ctx,
repo,
func(r *types.Repository) error {
if repo.Deleted == nil {
return gitness_store.ErrResourceNotFound
}
return mutateFn(r)
},
)
}
// updateOptLock updates the repository using the optimistic locking mechanism.
func (s *RepoStore) updateOptLock(
ctx context.Context,
repo *types.Repository, repo *types.Repository,
mutateFn func(repository *types.Repository) error, mutateFn func(repository *types.Repository) error,
) (*types.Repository, error) { ) (*types.Repository, error) {
@ -360,29 +428,61 @@ func (s *RepoStore) UpdateOptLock(ctx context.Context,
return nil, err return nil, err
} }
repo, err = s.Find(ctx, repo.ID) repo, err = s.find(ctx, repo.ID, repo.Deleted)
if err != nil { if err != nil {
return nil, err return nil, err
} }
} }
} }
// Delete the repository. // SoftDelete deletes a repo softly by setting the deleted timestamp.
func (s *RepoStore) Delete(ctx context.Context, id int64) error { func (s *RepoStore) SoftDelete(ctx context.Context, repo *types.Repository, deletedAt *int64) error {
_, err := s.UpdateOptLock(ctx, repo, func(r *types.Repository) error {
r.Deleted = deletedAt
return nil
})
if err != nil {
return fmt.Errorf("failed to soft delete repo: %w", err)
}
return nil
}
// Purge deletes the repo permanently.
func (s *RepoStore) Purge(ctx context.Context, id int64, deletedAt *int64) error {
const repoDelete = ` const repoDelete = `
DELETE FROM repositories DELETE FROM repositories
WHERE repo_id = $1` WHERE repo_id = $1 AND repo_deleted = $2`
db := dbtx.GetAccessor(ctx, s.db) db := dbtx.GetAccessor(ctx, s.db)
if _, err := db.ExecContext(ctx, repoDelete, id); err != nil { if _, err := db.ExecContext(ctx, repoDelete, id, deletedAt); err != nil {
return database.ProcessSQLErrorf(err, "the delete query failed") return database.ProcessSQLErrorf(err, "the delete query failed")
} }
return nil return nil
} }
// Count of repos in a space. if parentID (space) is zero then it will count all repositories in the system. // Restore restores a deleted repo.
func (s *RepoStore) Restore(
ctx context.Context,
repo *types.Repository,
newIdentifier string,
) (*types.Repository, error) {
repo, err := s.updateDeletedOptLock(ctx, repo, func(r *types.Repository) error {
r.Deleted = nil
if newIdentifier != "" {
r.Identifier = newIdentifier
}
return nil
})
if err != nil {
return nil, database.ProcessSQLErrorf(err, "failed to restore the repo")
}
return repo, nil
}
// Count of active repos in a space. if parentID (space) is zero then it will count all repositories in the system.
// With "DeletedBefore" filter, counts only deleted repos by opts.DeletedBefore.
func (s *RepoStore) Count(ctx context.Context, parentID int64, opts *types.RepoFilter) (int64, error) { func (s *RepoStore) Count(ctx context.Context, parentID int64, opts *types.RepoFilter) (int64, error) {
stmt := database.Builder. stmt := database.Builder.
Select("count(*)"). Select("count(*)").
@ -396,6 +496,12 @@ func (s *RepoStore) Count(ctx context.Context, parentID int64, opts *types.RepoF
stmt = stmt.Where("LOWER(repo_uid) LIKE ?", fmt.Sprintf("%%%s%%", strings.ToLower(opts.Query))) stmt = stmt.Where("LOWER(repo_uid) LIKE ?", fmt.Sprintf("%%%s%%", strings.ToLower(opts.Query)))
} }
if opts.DeletedBefore != nil {
stmt = stmt.Where("repo_deleted < ?", opts.DeletedBefore)
} else {
stmt = stmt.Where("repo_deleted IS NULL")
}
sql, args, err := stmt.ToSql() sql, args, err := stmt.ToSql()
if err != nil { if err != nil {
return 0, errors.Wrap(err, "Failed to convert query to sql") return 0, errors.Wrap(err, "Failed to convert query to sql")
@ -411,7 +517,7 @@ func (s *RepoStore) Count(ctx context.Context, parentID int64, opts *types.RepoF
return count, nil return count, nil
} }
// Count all repos in a hierarchy of spaces. // Count all active repos in a hierarchy of spaces.
func (s *RepoStore) CountAll(ctx context.Context, spaceID int64) (int64, error) { func (s *RepoStore) CountAll(ctx context.Context, spaceID int64) (int64, error) {
query := `WITH RECURSIVE SpaceHierarchy AS ( query := `WITH RECURSIVE SpaceHierarchy AS (
SELECT space_id, space_parent_id SELECT space_id, space_parent_id
@ -435,7 +541,7 @@ FROM SpaceHierarchy h1;`
} }
query = fmt.Sprintf( query = fmt.Sprintf(
"SELECT COUNT(repo_id) FROM repositories WHERE repo_parent_id IN (%s);", "SELECT COUNT(repo_id) FROM repositories WHERE repo_parent_id IN (%s) AND repo_deleted IS NULL;",
intsToCSV(spaceIDs), intsToCSV(spaceIDs),
) )
@ -447,13 +553,20 @@ FROM SpaceHierarchy h1;`
return numRepos, nil return numRepos, nil
} }
// List returns a list of repos in a space. // List returns a list of active repos in a space.
// With "DeletedBefore" filter, shows deleted repos by opts.DeletedBefore.
func (s *RepoStore) List(ctx context.Context, parentID int64, opts *types.RepoFilter) ([]*types.Repository, error) { func (s *RepoStore) List(ctx context.Context, parentID int64, opts *types.RepoFilter) ([]*types.Repository, error) {
stmt := database.Builder. stmt := database.Builder.
Select(repoColumnsForJoin). Select(repoColumnsForJoin).
From("repositories"). From("repositories").
Where("repo_parent_id = ?", fmt.Sprint(parentID)) Where("repo_parent_id = ?", fmt.Sprint(parentID))
if opts.DeletedBefore != nil {
stmt = stmt.Where("repo_deleted < ?", opts.DeletedBefore)
} else {
stmt = stmt.Where("repo_deleted IS NULL")
}
if opts.Query != "" { if opts.Query != "" {
stmt = stmt.Where("LOWER(repo_uid) LIKE ?", fmt.Sprintf("%%%s%%", strings.ToLower(opts.Query))) stmt = stmt.Where("LOWER(repo_uid) LIKE ?", fmt.Sprintf("%%%s%%", strings.ToLower(opts.Query)))
} }
@ -472,6 +585,8 @@ func (s *RepoStore) List(ctx context.Context, parentID int64, opts *types.RepoFi
stmt = stmt.OrderBy("repo_created " + opts.Order.String()) stmt = stmt.OrderBy("repo_created " + opts.Order.String())
case enum.RepoAttrUpdated: case enum.RepoAttrUpdated:
stmt = stmt.OrderBy("repo_updated " + opts.Order.String()) stmt = stmt.OrderBy("repo_updated " + opts.Order.String())
case enum.RepoAttrDeleted:
stmt = stmt.OrderBy("repo_deleted " + opts.Order.String())
} }
sql, args, err := stmt.ToSql() sql, args, err := stmt.ToSql()
@ -499,7 +614,8 @@ type repoSize struct {
func (s *RepoStore) ListSizeInfos(ctx context.Context) ([]*types.RepositorySizeInfo, error) { func (s *RepoStore) ListSizeInfos(ctx context.Context) ([]*types.RepositorySizeInfo, error) {
stmt := database.Builder. stmt := database.Builder.
Select("repo_id", "repo_git_uid", "repo_size", "repo_size_updated"). Select("repo_id", "repo_git_uid", "repo_size", "repo_size_updated").
From("repositories") From("repositories").
Where("repo_deleted IS NULL")
sql, args, err := stmt.ToSql() sql, args, err := stmt.ToSql()
if err != nil { if err != nil {
@ -531,6 +647,7 @@ func (s *RepoStore) mapToRepo(
Created: in.Created, Created: in.Created,
CreatedBy: in.CreatedBy, CreatedBy: in.CreatedBy,
Updated: in.Updated, Updated: in.Updated,
Deleted: in.Deleted.Ptr(),
Size: in.Size, Size: in.Size,
SizeUpdated: in.SizeUpdated, SizeUpdated: in.SizeUpdated,
GitUID: in.GitUID, GitUID: in.GitUID,
@ -609,6 +726,7 @@ func mapToInternalRepo(in *types.Repository) *repository {
Created: in.Created, Created: in.Created,
CreatedBy: in.CreatedBy, CreatedBy: in.CreatedBy,
Updated: in.Updated, Updated: in.Updated,
Deleted: null.IntFromPtr(in.Deleted),
Size: in.Size, Size: in.Size,
SizeUpdated: in.SizeUpdated, SizeUpdated: in.SizeUpdated,
GitUID: in.GitUID, GitUID: in.GitUID,

View File

@ -343,7 +343,8 @@ func ProvidePubsubConfig(config *types.Config) pubsub.Config {
// ProvideCleanupConfig loads the cleanup service config from the main config. // ProvideCleanupConfig loads the cleanup service config from the main config.
func ProvideCleanupConfig(config *types.Config) cleanup.Config { func ProvideCleanupConfig(config *types.Config) cleanup.Config {
return cleanup.Config{ return cleanup.Config{
WebhookExecutionsRetentionTime: config.Webhook.RetentionTime, WebhookExecutionsRetentionTime: config.Webhook.RetentionTime,
DeletedRepositoriesRetentionTime: config.Repos.DeletedRetentionTime,
} }
} }

View File

@ -143,11 +143,11 @@ func initSystem(ctx context.Context, config *types.Config) (*cliserver.System, e
lock.WireSet, lock.WireSet,
cliserver.ProvidePubsubConfig, cliserver.ProvidePubsubConfig,
pubsub.WireSet, pubsub.WireSet,
cliserver.ProvideJobsConfig,
job.WireSet,
cliserver.ProvideCleanupConfig, cliserver.ProvideCleanupConfig,
cleanup.WireSet, cleanup.WireSet,
codecomments.WireSet, codecomments.WireSet,
cliserver.ProvideJobsConfig,
job.WireSet,
protection.WireSet, protection.WireSet,
checkcontroller.WireSet, checkcontroller.WireSet,
execution.WireSet, execution.WireSet,

View File

@ -304,7 +304,7 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro
return nil, err return nil, err
} }
cleanupConfig := server.ProvideCleanupConfig(config) cleanupConfig := server.ProvideCleanupConfig(config)
cleanupService, err := cleanup.ProvideService(cleanupConfig, jobScheduler, executor, webhookExecutionStore, tokenStore) cleanupService, err := cleanup.ProvideService(cleanupConfig, jobScheduler, executor, webhookExecutionStore, tokenStore, repoStore, repoController)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -225,7 +225,7 @@ func (s *Service) DeleteRepositoryBestEffort(ctx context.Context, repoUID string
repoPath := getFullPathForRepo(s.reposRoot, repoUID) repoPath := getFullPathForRepo(s.reposRoot, repoUID)
tempPath := path.Join(s.reposGraveyard, repoUID) tempPath := path.Join(s.reposGraveyard, repoUID)
// delete should not fail if repoGraveyard dir does not exist // delete should not fail if repoGraveyard dir does not exist.
if _, err := os.Stat(s.reposGraveyard); os.IsNotExist(err) { if _, err := os.Stat(s.reposGraveyard); os.IsNotExist(err) {
if errdir := os.MkdirAll(s.reposGraveyard, fileMode700); errdir != nil { if errdir := os.MkdirAll(s.reposGraveyard, fileMode700); errdir != nil {
return fmt.Errorf("clean up dir '%s' doesn't exist and can't be created: %w", s.reposGraveyard, errdir) return fmt.Errorf("clean up dir '%s' doesn't exist and can't be created: %w", s.reposGraveyard, errdir)

View File

@ -348,4 +348,9 @@ type Config struct {
Concurrency int `envconfig:"GITNESS_KEYWORD_SEARCH_CONCURRENCY" default:"4"` Concurrency int `envconfig:"GITNESS_KEYWORD_SEARCH_CONCURRENCY" default:"4"`
MaxRetries int `envconfig:"GITNESS_KEYWORD_SEARCH_MAX_RETRIES" default:"3"` MaxRetries int `envconfig:"GITNESS_KEYWORD_SEARCH_MAX_RETRIES" default:"3"`
} }
Repos struct {
// DeletedRetentionTime is the duration after which deleted repositories will be purged.
DeletedRetentionTime time.Duration `envconfig:"GITNESS_REPOS_DELETED_RETENTION_TIME" default:"2160h"` // 90 days
}
} }

View File

@ -48,6 +48,8 @@ const (
updated = "updated" updated = "updated"
updatedAt = "updated_at" updatedAt = "updated_at"
updatedBy = "updated_by" updatedBy = "updated_by"
deleted = "deleted"
deletedAt = "deleted_at"
displayName = "display_name" displayName = "display_name"
date = "date" date = "date"
defaultString = "default" defaultString = "default"

View File

@ -29,6 +29,7 @@ const (
RepoAttrIdentifier RepoAttrIdentifier
RepoAttrCreated RepoAttrCreated
RepoAttrUpdated RepoAttrUpdated
RepoAttrDeleted
) )
// ParseRepoAttr parses the repo attribute string // ParseRepoAttr parses the repo attribute string
@ -44,6 +45,8 @@ func ParseRepoAttr(s string) RepoAttr {
return RepoAttrCreated return RepoAttrCreated
case updated, updatedAt: case updated, updatedAt:
return RepoAttrUpdated return RepoAttrUpdated
case deleted, deletedAt:
return RepoAttrDeleted
default: default:
return RepoAttrNone return RepoAttrNone
} }
@ -61,6 +64,8 @@ func (a RepoAttr) String() string {
return created return created
case RepoAttrUpdated: case RepoAttrUpdated:
return updated return updated
case RepoAttrDeleted:
return deleted
case RepoAttrNone: case RepoAttrNone:
return "" return ""
default: default:

View File

@ -33,6 +33,7 @@ type Repository struct {
CreatedBy int64 `json:"created_by"` CreatedBy int64 `json:"created_by"`
Created int64 `json:"created"` Created int64 `json:"created"`
Updated int64 `json:"updated"` Updated int64 `json:"updated"`
Deleted *int64 `json:"deleted,omitempty"`
Size int64 `json:"size"` Size int64 `json:"size"`
SizeUpdated int64 `json:"size_updated"` SizeUpdated int64 `json:"size_updated"`
@ -80,11 +81,12 @@ func (r Repository) GetGitUID() string {
// RepoFilter stores repo query parameters. // RepoFilter stores repo query parameters.
type RepoFilter struct { type RepoFilter struct {
Page int `json:"page"` Page int `json:"page"`
Size int `json:"size"` Size int `json:"size"`
Query string `json:"query"` Query string `json:"query"`
Sort enum.RepoAttr `json:"sort"` Sort enum.RepoAttr `json:"sort"`
Order enum.Order `json:"order"` Order enum.Order `json:"order"`
DeletedBefore *int64 `json:"deleted_before,omitempty"`
} }
// RepositoryGitInfo holds git info for a repository. // RepositoryGitInfo holds git info for a repository.