Move doctor package from modules to services (#28856)

This commit is contained in:
Lunny Xiao 2024-01-20 10:07:31 +08:00 committed by GitHub
parent d68a613ba8
commit 62f995203a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2 additions and 2 deletions

View file

@ -1,96 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package doctor
import (
"bufio"
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
asymkey_model "code.gitea.io/gitea/models/asymkey"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
)
const tplCommentPrefix = `# gitea public key`
func checkAuthorizedKeys(ctx context.Context, logger log.Logger, autofix bool) error {
if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedKeysFile {
return nil
}
fPath := filepath.Join(setting.SSH.RootPath, "authorized_keys")
f, err := os.Open(fPath)
if err != nil {
if !autofix {
logger.Critical("Unable to open authorized_keys file. ERROR: %v", err)
return fmt.Errorf("Unable to open authorized_keys file. ERROR: %w", err)
}
logger.Warn("Unable to open authorized_keys. (ERROR: %v). Attempting to rewrite...", err)
if err = asymkey_model.RewriteAllPublicKeys(ctx); err != nil {
logger.Critical("Unable to rewrite authorized_keys file. ERROR: %v", err)
return fmt.Errorf("Unable to rewrite authorized_keys file. ERROR: %w", err)
}
}
defer f.Close()
linesInAuthorizedKeys := make(container.Set[string])
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, tplCommentPrefix) {
continue
}
linesInAuthorizedKeys.Add(line)
}
f.Close()
// now we regenerate and check if there are any lines missing
regenerated := &bytes.Buffer{}
if err := asymkey_model.RegeneratePublicKeys(ctx, regenerated); err != nil {
logger.Critical("Unable to regenerate authorized_keys file. ERROR: %v", err)
return fmt.Errorf("Unable to regenerate authorized_keys file. ERROR: %w", err)
}
scanner = bufio.NewScanner(regenerated)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, tplCommentPrefix) {
continue
}
if linesInAuthorizedKeys.Contains(line) {
continue
}
if !autofix {
logger.Critical(
"authorized_keys file %q is out of date.\nRegenerate it with:\n\t\"%s\"\nor\n\t\"%s\"",
fPath,
"gitea admin regenerate keys",
"gitea doctor --run authorized-keys --fix")
return fmt.Errorf(`authorized_keys is out of date and should be regenerated with "gitea admin regenerate keys" or "gitea doctor --run authorized-keys --fix"`)
}
logger.Warn("authorized_keys is out of date. Attempting rewrite...")
err = asymkey_model.RewriteAllPublicKeys(ctx)
if err != nil {
logger.Critical("Unable to rewrite authorized_keys file. ERROR: %v", err)
return fmt.Errorf("Unable to rewrite authorized_keys file. ERROR: %w", err)
}
}
return nil
}
func init() {
Register(&Check{
Title: "Check if OpenSSH authorized_keys file is up-to-date",
Name: "authorized-keys",
IsDefault: true,
Run: checkAuthorizedKeys,
Priority: 4,
})
}

View file

@ -1,97 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package doctor
import (
"context"
"fmt"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"xorm.io/builder"
)
func iterateUserAccounts(ctx context.Context, each func(*user.User) error) error {
err := db.Iterate(
ctx,
builder.Gt{"id": 0},
func(ctx context.Context, bean *user.User) error {
return each(bean)
},
)
return err
}
// Since 1.16.4 new restrictions has been set on email addresses. However users with invalid email
// addresses would be currently facing a error due to their invalid email address.
// Ref: https://github.com/go-gitea/gitea/pull/19085 & https://github.com/go-gitea/gitea/pull/17688
func checkUserEmail(ctx context.Context, logger log.Logger, _ bool) error {
// We could use quirky SQL to get all users that start without a [a-zA-Z0-9], but that would mean
// DB provider-specific SQL and only works _now_. So instead we iterate through all user accounts
// and use the user.ValidateEmail function to be future-proof.
var invalidUserCount int64
if err := iterateUserAccounts(ctx, func(u *user.User) error {
// Only check for users, skip
if u.Type != user.UserTypeIndividual {
return nil
}
if err := user.ValidateEmail(u.Email); err != nil {
invalidUserCount++
logger.Warn("User[id=%d name=%q] have not a valid e-mail: %v", u.ID, u.Name, err)
}
return nil
}); err != nil {
return fmt.Errorf("iterateUserAccounts: %w", err)
}
if invalidUserCount == 0 {
logger.Info("All users have a valid e-mail.")
} else {
logger.Warn("%d user(s) have a non-valid e-mail.", invalidUserCount)
}
return nil
}
// From time to time Gitea makes changes to the reserved usernames and which symbols
// are allowed for various reasons. This check helps with detecting users that, according
// to our reserved names, don't have a valid username.
func checkUserName(ctx context.Context, logger log.Logger, _ bool) error {
var invalidUserCount int64
if err := iterateUserAccounts(ctx, func(u *user.User) error {
if err := user.IsUsableUsername(u.Name); err != nil {
invalidUserCount++
logger.Warn("User[id=%d] does not have a valid username: %v", u.ID, err)
}
return nil
}); err != nil {
return fmt.Errorf("iterateUserAccounts: %w", err)
}
if invalidUserCount == 0 {
logger.Info("All users have a valid username.")
} else {
logger.Warn("%d user(s) have a non-valid username.", invalidUserCount)
}
return nil
}
func init() {
Register(&Check{
Title: "Check if users has an valid email address",
Name: "check-user-email",
IsDefault: false,
Run: checkUserEmail,
Priority: 9,
})
Register(&Check{
Title: "Check if users have a valid username",
Name: "check-user-names",
IsDefault: false,
Run: checkUserName,
Priority: 9,
})
}

View file

@ -1,59 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package doctor
import (
"context"
"os"
"path/filepath"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
)
func checkOldArchives(ctx context.Context, logger log.Logger, autofix bool) error {
numRepos := 0
numReposUpdated := 0
err := iterateRepositories(ctx, func(repo *repo_model.Repository) error {
if repo.IsEmpty {
return nil
}
p := filepath.Join(repo.RepoPath(), "archives")
isDir, err := util.IsDir(p)
if err != nil {
log.Warn("check if %s is directory failed: %v", p, err)
}
if isDir {
numRepos++
if autofix {
if err := os.RemoveAll(p); err == nil {
numReposUpdated++
} else {
log.Warn("remove %s failed: %v", p, err)
}
}
}
return nil
})
if autofix {
logger.Info("%d / %d old archives in repository deleted", numReposUpdated, numRepos)
} else {
logger.Info("%d old archives in repository need to be deleted", numRepos)
}
return err
}
func init() {
Register(&Check{
Title: "Check old archives",
Name: "check-old-archives",
IsDefault: false,
Run: checkOldArchives,
Priority: 7,
})
}

View file

@ -1,245 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package doctor
import (
"context"
actions_model "code.gitea.io/gitea/models/actions"
activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/migrations"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
)
type consistencyCheck struct {
Name string
Counter func(context.Context) (int64, error)
Fixer func(context.Context) (int64, error)
FixedMessage string
}
func (c *consistencyCheck) Run(ctx context.Context, logger log.Logger, autofix bool) error {
count, err := c.Counter(ctx)
if err != nil {
logger.Critical("Error: %v whilst counting %s", err, c.Name)
return err
}
if count > 0 {
if autofix {
var fixed int64
if fixed, err = c.Fixer(ctx); err != nil {
logger.Critical("Error: %v whilst fixing %s", err, c.Name)
return err
}
prompt := "Deleted"
if c.FixedMessage != "" {
prompt = c.FixedMessage
}
if fixed < 0 {
logger.Info(prompt+" %d %s", count, c.Name)
} else {
logger.Info(prompt+" %d/%d %s", fixed, count, c.Name)
}
} else {
logger.Warn("Found %d %s", count, c.Name)
}
}
return nil
}
func asFixer(fn func(ctx context.Context) error) func(ctx context.Context) (int64, error) {
return func(ctx context.Context) (int64, error) {
err := fn(ctx)
return -1, err
}
}
func genericOrphanCheck(name, subject, refobject, joincond string) consistencyCheck {
return consistencyCheck{
Name: name,
Counter: func(ctx context.Context) (int64, error) {
return db.CountOrphanedObjects(ctx, subject, refobject, joincond)
},
Fixer: func(ctx context.Context) (int64, error) {
err := db.DeleteOrphanedObjects(ctx, subject, refobject, joincond)
return -1, err
},
}
}
func checkDBConsistency(ctx context.Context, logger log.Logger, autofix bool) error {
// make sure DB version is uptodate
if err := db.InitEngineWithMigration(ctx, migrations.EnsureUpToDate); err != nil {
logger.Critical("Model version on the database does not match the current Gitea version. Model consistency will not be checked until the database is upgraded")
return err
}
consistencyChecks := []consistencyCheck{
{
// find labels without existing repo or org
Name: "Orphaned Labels without existing repository or organisation",
Counter: issues_model.CountOrphanedLabels,
Fixer: asFixer(issues_model.DeleteOrphanedLabels),
},
{
// find IssueLabels without existing label
Name: "Orphaned Issue Labels without existing label",
Counter: issues_model.CountOrphanedIssueLabels,
Fixer: asFixer(issues_model.DeleteOrphanedIssueLabels),
},
{
// find issues without existing repository
Name: "Orphaned Issues without existing repository",
Counter: issues_model.CountOrphanedIssues,
Fixer: asFixer(issues_model.DeleteOrphanedIssues),
},
// find releases without existing repository
genericOrphanCheck("Orphaned Releases without existing repository",
"release", "repository", "`release`.repo_id=repository.id"),
// find pulls without existing issues
genericOrphanCheck("Orphaned PullRequests without existing issue",
"pull_request", "issue", "pull_request.issue_id=issue.id"),
// find pull requests without base repository
genericOrphanCheck("Pull request entries without existing base repository",
"pull_request", "repository", "pull_request.base_repo_id=repository.id"),
// find tracked times without existing issues/pulls
genericOrphanCheck("Orphaned TrackedTimes without existing issue",
"tracked_time", "issue", "tracked_time.issue_id=issue.id"),
// find attachments without existing issues or releases
{
Name: "Orphaned Attachments without existing issues or releases",
Counter: repo_model.CountOrphanedAttachments,
Fixer: asFixer(repo_model.DeleteOrphanedAttachments),
},
// find null archived repositories
{
Name: "Repositories with is_archived IS NULL",
Counter: repo_model.CountNullArchivedRepository,
Fixer: repo_model.FixNullArchivedRepository,
FixedMessage: "Fixed",
},
// find label comments with empty labels
{
Name: "Label comments with empty labels",
Counter: issues_model.CountCommentTypeLabelWithEmptyLabel,
Fixer: issues_model.FixCommentTypeLabelWithEmptyLabel,
FixedMessage: "Fixed",
},
// find label comments with labels from outside the repository
{
Name: "Label comments with labels from outside the repository",
Counter: issues_model.CountCommentTypeLabelWithOutsideLabels,
Fixer: issues_model.FixCommentTypeLabelWithOutsideLabels,
FixedMessage: "Removed",
},
// find issue_label with labels from outside the repository
{
Name: "IssueLabels with Labels from outside the repository",
Counter: issues_model.CountIssueLabelWithOutsideLabels,
Fixer: issues_model.FixIssueLabelWithOutsideLabels,
FixedMessage: "Removed",
},
{
Name: "Action with created_unix set as an empty string",
Counter: activities_model.CountActionCreatedUnixString,
Fixer: activities_model.FixActionCreatedUnixString,
FixedMessage: "Set to zero",
},
{
Name: "Action Runners without existing owner",
Counter: actions_model.CountRunnersWithoutBelongingOwner,
Fixer: actions_model.FixRunnersWithoutBelongingOwner,
FixedMessage: "Removed",
},
{
Name: "Topics with empty repository count",
Counter: repo_model.CountOrphanedTopics,
Fixer: repo_model.DeleteOrphanedTopics,
FixedMessage: "Removed",
},
}
// TODO: function to recalc all counters
if setting.Database.Type.IsPostgreSQL() {
consistencyChecks = append(consistencyChecks, consistencyCheck{
Name: "Sequence values",
Counter: db.CountBadSequences,
Fixer: asFixer(db.FixBadSequences),
FixedMessage: "Updated",
})
}
consistencyChecks = append(consistencyChecks,
// find protected branches without existing repository
genericOrphanCheck("Protected Branches without existing repository",
"protected_branch", "repository", "protected_branch.repo_id=repository.id"),
// find branches without existing repository
genericOrphanCheck("Branches without existing repository",
"branch", "repository", "branch.repo_id=repository.id"),
// find LFS locks without existing repository
genericOrphanCheck("LFS locks without existing repository",
"lfs_lock", "repository", "lfs_lock.repo_id=repository.id"),
// find collaborations without users
genericOrphanCheck("Collaborations without existing user",
"collaboration", "user", "collaboration.user_id=`user`.id"),
// find collaborations without repository
genericOrphanCheck("Collaborations without existing repository",
"collaboration", "repository", "collaboration.repo_id=repository.id"),
// find access without users
genericOrphanCheck("Access entries without existing user",
"access", "user", "access.user_id=`user`.id"),
// find access without repository
genericOrphanCheck("Access entries without existing repository",
"access", "repository", "access.repo_id=repository.id"),
// find action without repository
genericOrphanCheck("Action entries without existing repository",
"action", "repository", "action.repo_id=repository.id"),
// find action without user
genericOrphanCheck("Action entries without existing user",
"action", "user", "action.act_user_id=`user`.id"),
// find OAuth2Grant without existing user
genericOrphanCheck("Orphaned OAuth2Grant without existing User",
"oauth2_grant", "user", "oauth2_grant.user_id=`user`.id"),
// find OAuth2Application without existing user
genericOrphanCheck("Orphaned OAuth2Application without existing User",
"oauth2_application", "user", "oauth2_application.uid=`user`.id"),
// find OAuth2AuthorizationCode without existing OAuth2Grant
genericOrphanCheck("Orphaned OAuth2AuthorizationCode without existing OAuth2Grant",
"oauth2_authorization_code", "oauth2_grant", "oauth2_authorization_code.grant_id=oauth2_grant.id"),
// find stopwatches without existing user
genericOrphanCheck("Orphaned Stopwatches without existing User",
"stopwatch", "user", "stopwatch.user_id=`user`.id"),
// find stopwatches without existing issue
genericOrphanCheck("Orphaned Stopwatches without existing Issue",
"stopwatch", "issue", "stopwatch.issue_id=`issue`.id"),
// find redirects without existing user.
genericOrphanCheck("Orphaned Redirects without existing redirect user",
"user_redirect", "user", "user_redirect.redirect_user_id=`user`.id"),
)
for _, c := range consistencyChecks {
if err := c.Run(ctx, logger, autofix); err != nil {
return err
}
}
return nil
}
func init() {
Register(&Check{
Title: "Check consistency of database",
Name: "check-db-consistency",
IsDefault: false,
Run: checkDBConsistency,
Priority: 3,
})
}

View file

@ -1,42 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package doctor
import (
"context"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/migrations"
"code.gitea.io/gitea/modules/log"
)
func checkDBVersion(ctx context.Context, logger log.Logger, autofix bool) error {
logger.Info("Expected database version: %d", migrations.ExpectedVersion())
if err := db.InitEngineWithMigration(ctx, migrations.EnsureUpToDate); err != nil {
if !autofix {
logger.Critical("Error: %v during ensure up to date", err)
return err
}
logger.Warn("Got Error: %v during ensure up to date", err)
logger.Warn("Attempting to migrate to the latest DB version to fix this.")
err = db.InitEngineWithMigration(ctx, migrations.Migrate)
if err != nil {
logger.Critical("Error: %v during migration", err)
}
return err
}
return nil
}
func init() {
Register(&Check{
Title: "Check Database Version",
Name: "check-db-version",
IsDefault: true,
Run: checkDBVersion,
AbortIfFailed: false,
Priority: 2,
})
}

View file

@ -1,127 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package doctor
import (
"context"
"fmt"
"os"
"sort"
"strings"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
)
// Check represents a Doctor check
type Check struct {
Title string
Name string
IsDefault bool
Run func(ctx context.Context, logger log.Logger, autofix bool) error
AbortIfFailed bool
SkipDatabaseInitialization bool
Priority int
}
func initDBSkipLogger(ctx context.Context) error {
setting.MustInstalled()
setting.LoadDBSetting()
if err := db.InitEngine(ctx); err != nil {
return fmt.Errorf("db.InitEngine: %w", err)
}
// some doctor sub-commands need to use git command
if err := git.InitFull(ctx); err != nil {
return fmt.Errorf("git.InitFull: %w", err)
}
return nil
}
type doctorCheckLogger struct {
colorize bool
}
var _ log.BaseLogger = (*doctorCheckLogger)(nil)
func (d *doctorCheckLogger) Log(skip int, level log.Level, format string, v ...any) {
_, _ = fmt.Fprintf(os.Stdout, format+"\n", v...)
}
func (d *doctorCheckLogger) GetLevel() log.Level {
return log.TRACE
}
type doctorCheckStepLogger struct {
colorize bool
}
var _ log.BaseLogger = (*doctorCheckStepLogger)(nil)
func (d *doctorCheckStepLogger) Log(skip int, level log.Level, format string, v ...any) {
levelChar := fmt.Sprintf("[%s]", strings.ToUpper(level.String()[0:1]))
var levelArg any = levelChar
if d.colorize {
levelArg = log.NewColoredValue(levelChar, level.ColorAttributes()...)
}
args := append([]any{levelArg}, v...)
_, _ = fmt.Fprintf(os.Stdout, " - %s "+format+"\n", args...)
}
func (d *doctorCheckStepLogger) GetLevel() log.Level {
return log.TRACE
}
// Checks is the list of available commands
var Checks []*Check
// RunChecks runs the doctor checks for the provided list
func RunChecks(ctx context.Context, colorize, autofix bool, checks []*Check) error {
SortChecks(checks)
// the checks output logs by a special logger, they do not use the default logger
logger := log.BaseLoggerToGeneralLogger(&doctorCheckLogger{colorize: colorize})
loggerStep := log.BaseLoggerToGeneralLogger(&doctorCheckStepLogger{colorize: colorize})
dbIsInit := false
for i, check := range checks {
if !dbIsInit && !check.SkipDatabaseInitialization {
// Only open database after the most basic configuration check
if err := initDBSkipLogger(ctx); err != nil {
logger.Error("Error whilst initializing the database: %v", err)
logger.Error("Check if you are using the right config file. You can use a --config directive to specify one.")
return nil
}
dbIsInit = true
}
logger.Info("\n[%d] %s", i+1, check.Title)
if err := check.Run(ctx, loggerStep, autofix); err != nil {
if check.AbortIfFailed {
logger.Critical("FAIL")
return err
}
logger.Error("ERROR")
} else {
logger.Info("OK")
}
}
logger.Info("\nAll done (checks: %d).", len(checks))
return nil
}
// Register registers a command with the list
func Register(command *Check) {
Checks = append(Checks, command)
}
func SortChecks(checks []*Check) {
sort.SliceStable(checks, func(i, j int) bool {
if checks[i].Priority == checks[j].Priority {
return checks[i].Name < checks[j].Name
}
if checks[i].Priority == 0 {
return false
}
return checks[i].Priority < checks[j].Priority
})
}

View file

@ -1,322 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package doctor
import (
"bytes"
"context"
"errors"
"fmt"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/timeutil"
"xorm.io/builder"
)
// #16831 revealed that the dump command that was broken in 1.14.3-1.14.6 and 1.15.0 (#15885).
// This led to repo_unit and login_source cfg not being converted to JSON in the dump
// Unfortunately although it was hoped that there were only a few users affected it
// appears that many users are affected.
// We therefore need to provide a doctor command to fix this repeated issue #16961
func parseBool16961(bs []byte) (bool, error) {
if bytes.EqualFold(bs, []byte("%!s(bool=false)")) {
return false, nil
}
if bytes.EqualFold(bs, []byte("%!s(bool=true)")) {
return true, nil
}
return false, fmt.Errorf("unexpected bool format: %s", string(bs))
}
func fixUnitConfig16961(bs []byte, cfg *repo_model.UnitConfig) (fixed bool, err error) {
err = json.UnmarshalHandleDoubleEncode(bs, &cfg)
if err == nil {
return false, nil
}
// Handle #16961
if string(bs) != "&{}" && len(bs) != 0 {
return false, err
}
return true, nil
}
func fixExternalWikiConfig16961(bs []byte, cfg *repo_model.ExternalWikiConfig) (fixed bool, err error) {
err = json.UnmarshalHandleDoubleEncode(bs, &cfg)
if err == nil {
return false, nil
}
if len(bs) < 3 {
return false, err
}
if bs[0] != '&' || bs[1] != '{' || bs[len(bs)-1] != '}' {
return false, err
}
cfg.ExternalWikiURL = string(bs[2 : len(bs)-1])
return true, nil
}
func fixExternalTrackerConfig16961(bs []byte, cfg *repo_model.ExternalTrackerConfig) (fixed bool, err error) {
err = json.UnmarshalHandleDoubleEncode(bs, &cfg)
if err == nil {
return false, nil
}
// Handle #16961
if len(bs) < 3 {
return false, err
}
if bs[0] != '&' || bs[1] != '{' || bs[len(bs)-1] != '}' {
return false, err
}
parts := bytes.Split(bs[2:len(bs)-1], []byte{' '})
if len(parts) != 3 {
return false, err
}
cfg.ExternalTrackerURL = string(bytes.Join(parts[:len(parts)-2], []byte{' '}))
cfg.ExternalTrackerFormat = string(parts[len(parts)-2])
cfg.ExternalTrackerStyle = string(parts[len(parts)-1])
return true, nil
}
func fixPullRequestsConfig16961(bs []byte, cfg *repo_model.PullRequestsConfig) (fixed bool, err error) {
err = json.UnmarshalHandleDoubleEncode(bs, &cfg)
if err == nil {
return false, nil
}
// Handle #16961
if len(bs) < 3 {
return false, err
}
if bs[0] != '&' || bs[1] != '{' || bs[len(bs)-1] != '}' {
return false, err
}
// PullRequestsConfig was the following in 1.14
// type PullRequestsConfig struct {
// IgnoreWhitespaceConflicts bool
// AllowMerge bool
// AllowRebase bool
// AllowRebaseMerge bool
// AllowSquash bool
// AllowManualMerge bool
// AutodetectManualMerge bool
// }
//
// 1.15 added in addition:
// DefaultDeleteBranchAfterMerge bool
// DefaultMergeStyle MergeStyle
parts := bytes.Split(bs[2:len(bs)-1], []byte{' '})
if len(parts) < 7 {
return false, err
}
var parseErr error
cfg.IgnoreWhitespaceConflicts, parseErr = parseBool16961(parts[0])
if parseErr != nil {
return false, errors.Join(err, parseErr)
}
cfg.AllowMerge, parseErr = parseBool16961(parts[1])
if parseErr != nil {
return false, errors.Join(err, parseErr)
}
cfg.AllowRebase, parseErr = parseBool16961(parts[2])
if parseErr != nil {
return false, errors.Join(err, parseErr)
}
cfg.AllowRebaseMerge, parseErr = parseBool16961(parts[3])
if parseErr != nil {
return false, errors.Join(err, parseErr)
}
cfg.AllowSquash, parseErr = parseBool16961(parts[4])
if parseErr != nil {
return false, errors.Join(err, parseErr)
}
cfg.AllowManualMerge, parseErr = parseBool16961(parts[5])
if parseErr != nil {
return false, errors.Join(err, parseErr)
}
cfg.AutodetectManualMerge, parseErr = parseBool16961(parts[6])
if parseErr != nil {
return false, errors.Join(err, parseErr)
}
// 1.14 unit
if len(parts) == 7 {
return true, nil
}
if len(parts) < 9 {
return false, err
}
cfg.DefaultDeleteBranchAfterMerge, parseErr = parseBool16961(parts[7])
if parseErr != nil {
return false, errors.Join(err, parseErr)
}
cfg.DefaultMergeStyle = repo_model.MergeStyle(string(bytes.Join(parts[8:], []byte{' '})))
return true, nil
}
func fixIssuesConfig16961(bs []byte, cfg *repo_model.IssuesConfig) (fixed bool, err error) {
err = json.UnmarshalHandleDoubleEncode(bs, &cfg)
if err == nil {
return false, nil
}
// Handle #16961
if len(bs) < 3 {
return false, err
}
if bs[0] != '&' || bs[1] != '{' || bs[len(bs)-1] != '}' {
return false, err
}
parts := bytes.Split(bs[2:len(bs)-1], []byte{' '})
if len(parts) != 3 {
return false, err
}
var parseErr error
cfg.EnableTimetracker, parseErr = parseBool16961(parts[0])
if parseErr != nil {
return false, errors.Join(err, parseErr)
}
cfg.AllowOnlyContributorsToTrackTime, parseErr = parseBool16961(parts[1])
if parseErr != nil {
return false, errors.Join(err, parseErr)
}
cfg.EnableDependencies, parseErr = parseBool16961(parts[2])
if parseErr != nil {
return false, errors.Join(err, parseErr)
}
return true, nil
}
func fixBrokenRepoUnit16961(repoUnit *repo_model.RepoUnit, bs []byte) (fixed bool, err error) {
// Shortcut empty or null values
if len(bs) == 0 {
return false, nil
}
switch repoUnit.Type {
case unit.TypeCode, unit.TypeReleases, unit.TypeWiki, unit.TypeProjects:
cfg := &repo_model.UnitConfig{}
repoUnit.Config = cfg
if fixed, err := fixUnitConfig16961(bs, cfg); !fixed {
return false, err
}
case unit.TypeExternalWiki:
cfg := &repo_model.ExternalWikiConfig{}
repoUnit.Config = cfg
if fixed, err := fixExternalWikiConfig16961(bs, cfg); !fixed {
return false, err
}
case unit.TypeExternalTracker:
cfg := &repo_model.ExternalTrackerConfig{}
repoUnit.Config = cfg
if fixed, err := fixExternalTrackerConfig16961(bs, cfg); !fixed {
return false, err
}
case unit.TypePullRequests:
cfg := &repo_model.PullRequestsConfig{}
repoUnit.Config = cfg
if fixed, err := fixPullRequestsConfig16961(bs, cfg); !fixed {
return false, err
}
case unit.TypeIssues:
cfg := &repo_model.IssuesConfig{}
repoUnit.Config = cfg
if fixed, err := fixIssuesConfig16961(bs, cfg); !fixed {
return false, err
}
default:
panic(fmt.Sprintf("unrecognized repo unit type: %v", repoUnit.Type))
}
return true, nil
}
func fixBrokenRepoUnits16961(ctx context.Context, logger log.Logger, autofix bool) error {
// RepoUnit describes all units of a repository
type RepoUnit struct {
ID int64
RepoID int64
Type unit.Type
Config []byte
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
}
count := 0
err := db.Iterate(
ctx,
builder.Gt{
"id": 0,
},
func(ctx context.Context, unit *RepoUnit) error {
bs := unit.Config
repoUnit := &repo_model.RepoUnit{
ID: unit.ID,
RepoID: unit.RepoID,
Type: unit.Type,
CreatedUnix: unit.CreatedUnix,
}
if fixed, err := fixBrokenRepoUnit16961(repoUnit, bs); !fixed {
return err
}
count++
if !autofix {
return nil
}
return repo_model.UpdateRepoUnit(ctx, repoUnit)
},
)
if err != nil {
logger.Critical("Unable to iterate across repounits to fix the broken units: Error %v", err)
return err
}
if !autofix {
if count == 0 {
logger.Info("Found no broken repo_units")
} else {
logger.Warn("Found %d broken repo_units", count)
}
return nil
}
logger.Info("Fixed %d broken repo_units", count)
return nil
}
func init() {
Register(&Check{
Title: "Check for incorrectly dumped repo_units (See #16961)",
Name: "fix-broken-repo-units",
IsDefault: false,
Run: fixBrokenRepoUnits16961,
Priority: 7,
})
}

View file

@ -1,271 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package doctor
import (
"testing"
repo_model "code.gitea.io/gitea/models/repo"
"github.com/stretchr/testify/assert"
)
func Test_fixUnitConfig_16961(t *testing.T) {
tests := []struct {
name string
bs string
wantFixed bool
wantErr bool
}{
{
name: "empty",
bs: "",
wantFixed: true,
wantErr: false,
},
{
name: "normal: {}",
bs: "{}",
wantFixed: false,
wantErr: false,
},
{
name: "broken but fixable: &{}",
bs: "&{}",
wantFixed: true,
wantErr: false,
},
{
name: "broken but unfixable: &{asdasd}",
bs: "&{asdasd}",
wantFixed: false,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotFixed, err := fixUnitConfig16961([]byte(tt.bs), &repo_model.UnitConfig{})
if (err != nil) != tt.wantErr {
t.Errorf("fixUnitConfig_16961() error = %v, wantErr %v", err, tt.wantErr)
return
}
if gotFixed != tt.wantFixed {
t.Errorf("fixUnitConfig_16961() = %v, want %v", gotFixed, tt.wantFixed)
}
})
}
}
func Test_fixExternalWikiConfig_16961(t *testing.T) {
tests := []struct {
name string
bs string
expected string
wantFixed bool
wantErr bool
}{
{
name: "normal: {\"ExternalWikiURL\":\"http://someurl\"}",
bs: "{\"ExternalWikiURL\":\"http://someurl\"}",
expected: "http://someurl",
wantFixed: false,
wantErr: false,
},
{
name: "broken: &{http://someurl}",
bs: "&{http://someurl}",
expected: "http://someurl",
wantFixed: true,
wantErr: false,
},
{
name: "broken but unfixable: http://someurl",
bs: "http://someurl",
wantFixed: false,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &repo_model.ExternalWikiConfig{}
gotFixed, err := fixExternalWikiConfig16961([]byte(tt.bs), cfg)
if (err != nil) != tt.wantErr {
t.Errorf("fixExternalWikiConfig_16961() error = %v, wantErr %v", err, tt.wantErr)
return
}
if gotFixed != tt.wantFixed {
t.Errorf("fixExternalWikiConfig_16961() = %v, want %v", gotFixed, tt.wantFixed)
}
if cfg.ExternalWikiURL != tt.expected {
t.Errorf("fixExternalWikiConfig_16961().ExternalWikiURL = %v, want %v", cfg.ExternalWikiURL, tt.expected)
}
})
}
}
func Test_fixExternalTrackerConfig_16961(t *testing.T) {
tests := []struct {
name string
bs string
expected repo_model.ExternalTrackerConfig
wantFixed bool
wantErr bool
}{
{
name: "normal",
bs: `{"ExternalTrackerURL":"a","ExternalTrackerFormat":"b","ExternalTrackerStyle":"c"}`,
expected: repo_model.ExternalTrackerConfig{
ExternalTrackerURL: "a",
ExternalTrackerFormat: "b",
ExternalTrackerStyle: "c",
},
wantFixed: false,
wantErr: false,
},
{
name: "broken",
bs: "&{a b c}",
expected: repo_model.ExternalTrackerConfig{
ExternalTrackerURL: "a",
ExternalTrackerFormat: "b",
ExternalTrackerStyle: "c",
},
wantFixed: true,
wantErr: false,
},
{
name: "broken - too many fields",
bs: "&{a b c d}",
wantFixed: false,
wantErr: true,
},
{
name: "broken - wrong format",
bs: "a b c d}",
wantFixed: false,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &repo_model.ExternalTrackerConfig{}
gotFixed, err := fixExternalTrackerConfig16961([]byte(tt.bs), cfg)
if (err != nil) != tt.wantErr {
t.Errorf("fixExternalTrackerConfig_16961() error = %v, wantErr %v", err, tt.wantErr)
return
}
if gotFixed != tt.wantFixed {
t.Errorf("fixExternalTrackerConfig_16961() = %v, want %v", gotFixed, tt.wantFixed)
}
if cfg.ExternalTrackerFormat != tt.expected.ExternalTrackerFormat {
t.Errorf("fixExternalTrackerConfig_16961().ExternalTrackerFormat = %v, want %v", tt.expected.ExternalTrackerFormat, cfg.ExternalTrackerFormat)
}
if cfg.ExternalTrackerStyle != tt.expected.ExternalTrackerStyle {
t.Errorf("fixExternalTrackerConfig_16961().ExternalTrackerStyle = %v, want %v", tt.expected.ExternalTrackerStyle, cfg.ExternalTrackerStyle)
}
if cfg.ExternalTrackerURL != tt.expected.ExternalTrackerURL {
t.Errorf("fixExternalTrackerConfig_16961().ExternalTrackerURL = %v, want %v", tt.expected.ExternalTrackerURL, cfg.ExternalTrackerURL)
}
})
}
}
func Test_fixPullRequestsConfig_16961(t *testing.T) {
tests := []struct {
name string
bs string
expected repo_model.PullRequestsConfig
wantFixed bool
wantErr bool
}{
{
name: "normal",
bs: `{"IgnoreWhitespaceConflicts":false,"AllowMerge":false,"AllowRebase":false,"AllowRebaseMerge":false,"AllowSquash":false,"AllowManualMerge":false,"AutodetectManualMerge":false,"DefaultDeleteBranchAfterMerge":false,"DefaultMergeStyle":""}`,
},
{
name: "broken - 1.14",
bs: `&{%!s(bool=false) %!s(bool=true) %!s(bool=true) %!s(bool=true) %!s(bool=true) %!s(bool=false) %!s(bool=false)}`,
expected: repo_model.PullRequestsConfig{
IgnoreWhitespaceConflicts: false,
AllowMerge: true,
AllowRebase: true,
AllowRebaseMerge: true,
AllowSquash: true,
AllowManualMerge: false,
AutodetectManualMerge: false,
},
wantFixed: true,
},
{
name: "broken - 1.15",
bs: `&{%!s(bool=false) %!s(bool=true) %!s(bool=true) %!s(bool=true) %!s(bool=true) %!s(bool=false) %!s(bool=false) %!s(bool=false) merge}`,
expected: repo_model.PullRequestsConfig{
AllowMerge: true,
AllowRebase: true,
AllowRebaseMerge: true,
AllowSquash: true,
DefaultMergeStyle: repo_model.MergeStyleMerge,
},
wantFixed: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &repo_model.PullRequestsConfig{}
gotFixed, err := fixPullRequestsConfig16961([]byte(tt.bs), cfg)
if (err != nil) != tt.wantErr {
t.Errorf("fixPullRequestsConfig_16961() error = %v, wantErr %v", err, tt.wantErr)
return
}
if gotFixed != tt.wantFixed {
t.Errorf("fixPullRequestsConfig_16961() = %v, want %v", gotFixed, tt.wantFixed)
}
assert.EqualValues(t, &tt.expected, cfg)
})
}
}
func Test_fixIssuesConfig_16961(t *testing.T) {
tests := []struct {
name string
bs string
expected repo_model.IssuesConfig
wantFixed bool
wantErr bool
}{
{
name: "normal",
bs: `{"EnableTimetracker":true,"AllowOnlyContributorsToTrackTime":true,"EnableDependencies":true}`,
expected: repo_model.IssuesConfig{
EnableTimetracker: true,
AllowOnlyContributorsToTrackTime: true,
EnableDependencies: true,
},
},
{
name: "broken",
bs: `&{%!s(bool=true) %!s(bool=true) %!s(bool=true)}`,
expected: repo_model.IssuesConfig{
EnableTimetracker: true,
AllowOnlyContributorsToTrackTime: true,
EnableDependencies: true,
},
wantFixed: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &repo_model.IssuesConfig{}
gotFixed, err := fixIssuesConfig16961([]byte(tt.bs), cfg)
if (err != nil) != tt.wantErr {
t.Errorf("fixIssuesConfig_16961() error = %v, wantErr %v", err, tt.wantErr)
return
}
if gotFixed != tt.wantFixed {
t.Errorf("fixIssuesConfig_16961() = %v, want %v", gotFixed, tt.wantFixed)
}
assert.EqualValues(t, &tt.expected, cfg)
})
}
}

View file

@ -1,61 +0,0 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package doctor
import (
"context"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db"
org_model "code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
"code.gitea.io/gitea/modules/log"
"xorm.io/builder"
)
func fixOwnerTeamCreateOrgRepo(ctx context.Context, logger log.Logger, autofix bool) error {
count := 0
err := db.Iterate(
ctx,
builder.Eq{"authorize": perm.AccessModeOwner, "can_create_org_repo": false},
func(ctx context.Context, team *org_model.Team) error {
team.CanCreateOrgRepo = true
count++
if !autofix {
return nil
}
return models.UpdateTeam(ctx, team, false, false)
},
)
if err != nil {
logger.Critical("Unable to iterate across repounits to fix incorrect can_create_org_repo: Error %v", err)
return err
}
if !autofix {
if count == 0 {
logger.Info("Found no team with incorrect can_create_org_repo")
} else {
logger.Warn("Found %d teams with incorrect can_create_org_repo", count)
}
return nil
}
logger.Info("Fixed %d teams with incorrect can_create_org_repo", count)
return nil
}
func init() {
Register(&Check{
Title: "Check for incorrect can_create_org_repo for org owner teams",
Name: "fix-owner-team-create-org-repo",
IsDefault: false,
Run: fixOwnerTeamCreateOrgRepo,
Priority: 7,
})
}

View file

@ -1,88 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package doctor
import (
"context"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
)
func synchronizeRepoHeads(ctx context.Context, logger log.Logger, autofix bool) error {
numRepos := 0
numHeadsBroken := 0
numDefaultBranchesBroken := 0
numReposUpdated := 0
err := iterateRepositories(ctx, func(repo *repo_model.Repository) error {
numRepos++
_, _, defaultBranchErr := git.NewCommand(ctx, "rev-parse").AddDashesAndList(repo.DefaultBranch).RunStdString(&git.RunOpts{Dir: repo.RepoPath()})
head, _, headErr := git.NewCommand(ctx, "symbolic-ref", "--short", "HEAD").RunStdString(&git.RunOpts{Dir: repo.RepoPath()})
// what we expect: default branch is valid, and HEAD points to it
if headErr == nil && defaultBranchErr == nil && head == repo.DefaultBranch {
return nil
}
if headErr != nil {
numHeadsBroken++
}
if defaultBranchErr != nil {
numDefaultBranchesBroken++
}
// if default branch is broken, let the user fix that in the UI
if defaultBranchErr != nil {
logger.Warn("Default branch for %s/%s doesn't point to a valid commit", repo.OwnerName, repo.Name)
return nil
}
// if we're not autofixing, that's all we can do
if !autofix {
return nil
}
// otherwise, let's try fixing HEAD
err := git.NewCommand(ctx, "symbolic-ref").AddDashesAndList("HEAD", git.BranchPrefix+repo.DefaultBranch).Run(&git.RunOpts{Dir: repo.RepoPath()})
if err != nil {
logger.Warn("Failed to fix HEAD for %s/%s: %v", repo.OwnerName, repo.Name, err)
return nil
}
numReposUpdated++
return nil
})
if err != nil {
logger.Critical("Error when fixing repo HEADs: %v", err)
}
if autofix {
logger.Info("Out of %d repos, HEADs for %d are now fixed and HEADS for %d are still broken", numRepos, numReposUpdated, numDefaultBranchesBroken+numHeadsBroken-numReposUpdated)
} else {
if numHeadsBroken == 0 && numDefaultBranchesBroken == 0 {
logger.Info("All %d repos have their HEADs in the correct state", numRepos)
} else {
if numHeadsBroken == 0 && numDefaultBranchesBroken != 0 {
logger.Critical("Default branches are broken for %d/%d repos", numDefaultBranchesBroken, numRepos)
} else if numHeadsBroken != 0 && numDefaultBranchesBroken == 0 {
logger.Warn("HEADs are broken for %d/%d repos", numHeadsBroken, numRepos)
} else {
logger.Critical("Out of %d repos, HEADS are broken for %d and default branches are broken for %d", numRepos, numHeadsBroken, numDefaultBranchesBroken)
}
}
}
return err
}
func init() {
Register(&Check{
Title: "Synchronize repo HEADs",
Name: "synchronize-repo-heads",
IsDefault: true,
Run: synchronizeRepoHeads,
Priority: 7,
})
}

View file

@ -1,51 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package doctor
import (
"context"
"fmt"
"time"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/repository"
)
func init() {
Register(&Check{
Title: "Garbage collect LFS",
Name: "gc-lfs",
IsDefault: false,
Run: garbageCollectLFSCheck,
AbortIfFailed: false,
SkipDatabaseInitialization: false,
Priority: 1,
})
}
func garbageCollectLFSCheck(ctx context.Context, logger log.Logger, autofix bool) error {
if !setting.LFS.StartServer {
return fmt.Errorf("LFS support is disabled")
}
if err := repository.GarbageCollectLFSMetaObjects(ctx, repository.GarbageCollectLFSMetaObjectsOptions{
LogDetail: logger.Info,
AutoFix: autofix,
// Only attempt to garbage collect lfs meta objects older than a week as the order of git lfs upload
// and git object upload is not necessarily guaranteed. It's possible to imagine a situation whereby
// an LFS object is uploaded but the git branch is not uploaded immediately, or there are some rapid
// changes in new branches that might lead to lfs objects becoming temporarily unassociated with git
// objects.
//
// It is likely that a week is potentially excessive but it should definitely be enough that any
// unassociated LFS object is genuinely unassociated.
OlderThan: time.Now().Add(-24 * time.Hour * 7),
// We don't set the UpdatedLessRecentlyThan because we want to do a full GC
}); err != nil {
return err
}
return checkStorage(&checkStorageOptions{LFS: true})(ctx, logger, autofix)
}

View file

@ -1,114 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package doctor
import (
"context"
"fmt"
"strings"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
"xorm.io/builder"
)
func iteratePRs(ctx context.Context, repo *repo_model.Repository, each func(*repo_model.Repository, *issues_model.PullRequest) error) error {
return db.Iterate(
ctx,
builder.Eq{"base_repo_id": repo.ID},
func(ctx context.Context, bean *issues_model.PullRequest) error {
return each(repo, bean)
},
)
}
func checkPRMergeBase(ctx context.Context, logger log.Logger, autofix bool) error {
numRepos := 0
numPRs := 0
numPRsUpdated := 0
err := iterateRepositories(ctx, func(repo *repo_model.Repository) error {
numRepos++
return iteratePRs(ctx, repo, func(repo *repo_model.Repository, pr *issues_model.PullRequest) error {
numPRs++
pr.BaseRepo = repo
repoPath := repo.RepoPath()
oldMergeBase := pr.MergeBase
if !pr.HasMerged {
var err error
pr.MergeBase, _, err = git.NewCommand(ctx, "merge-base").AddDashesAndList(pr.BaseBranch, pr.GetGitRefName()).RunStdString(&git.RunOpts{Dir: repoPath})
if err != nil {
var err2 error
pr.MergeBase, _, err2 = git.NewCommand(ctx, "rev-parse").AddDynamicArguments(git.BranchPrefix + pr.BaseBranch).RunStdString(&git.RunOpts{Dir: repoPath})
if err2 != nil {
logger.Warn("Unable to get merge base for PR ID %d, #%d onto %s in %s/%s. Error: %v & %v", pr.ID, pr.Index, pr.BaseBranch, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, err, err2)
return nil
}
}
} else {
parentsString, _, err := git.NewCommand(ctx, "rev-list", "--parents", "-n", "1").AddDynamicArguments(pr.MergedCommitID).RunStdString(&git.RunOpts{Dir: repoPath})
if err != nil {
logger.Warn("Unable to get parents for merged PR ID %d, #%d onto %s in %s/%s. Error: %v", pr.ID, pr.Index, pr.BaseBranch, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, err)
return nil
}
parents := strings.Split(strings.TrimSpace(parentsString), " ")
if len(parents) < 2 {
return nil
}
refs := append([]string{}, parents[1:]...)
refs = append(refs, pr.GetGitRefName())
cmd := git.NewCommand(ctx, "merge-base").AddDashesAndList(refs...)
pr.MergeBase, _, err = cmd.RunStdString(&git.RunOpts{Dir: repoPath})
if err != nil {
logger.Warn("Unable to get merge base for merged PR ID %d, #%d onto %s in %s/%s. Error: %v", pr.ID, pr.Index, pr.BaseBranch, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, err)
return nil
}
}
pr.MergeBase = strings.TrimSpace(pr.MergeBase)
if pr.MergeBase != oldMergeBase {
if autofix {
if err := pr.UpdateCols(ctx, "merge_base"); err != nil {
logger.Critical("Failed to update merge_base. ERROR: %v", err)
return fmt.Errorf("Failed to update merge_base. ERROR: %w", err)
}
} else {
logger.Info("#%d onto %s in %s/%s: MergeBase should be %s but is %s", pr.Index, pr.BaseBranch, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, oldMergeBase, pr.MergeBase)
}
numPRsUpdated++
}
return nil
})
})
if autofix {
logger.Info("%d PR mergebases updated of %d PRs total in %d repos", numPRsUpdated, numPRs, numRepos)
} else {
if numPRsUpdated == 0 {
logger.Info("All %d PRs in %d repos have a correct mergebase", numPRs, numRepos)
} else if err == nil {
logger.Critical("%d PRs with incorrect mergebases of %d PRs total in %d repos", numPRsUpdated, numPRs, numRepos)
return fmt.Errorf("%d PRs with incorrect mergebases of %d PRs total in %d repos", numPRsUpdated, numPRs, numRepos)
} else {
logger.Warn("%d PRs with incorrect mergebases of %d PRs total in %d repos", numPRsUpdated, numPRs, numRepos)
}
}
return err
}
func init() {
Register(&Check{
Title: "Recalculate merge bases",
Name: "recalculate-merge-bases",
IsDefault: false,
Run: checkPRMergeBase,
Priority: 7,
})
}

View file

@ -1,298 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package doctor
import (
"context"
"fmt"
"os"
"os/exec"
"path"
"strings"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
lru "github.com/hashicorp/golang-lru/v2"
"xorm.io/builder"
)
func iterateRepositories(ctx context.Context, each func(*repo_model.Repository) error) error {
err := db.Iterate(
ctx,
builder.Gt{"id": 0},
func(ctx context.Context, bean *repo_model.Repository) error {
return each(bean)
},
)
return err
}
func checkScriptType(ctx context.Context, logger log.Logger, autofix bool) error {
path, err := exec.LookPath(setting.ScriptType)
if err != nil {
logger.Critical("ScriptType \"%q\" is not on the current PATH. Error: %v", setting.ScriptType, err)
return fmt.Errorf("ScriptType \"%q\" is not on the current PATH. Error: %w", setting.ScriptType, err)
}
logger.Info("ScriptType %s is on the current PATH at %s", setting.ScriptType, path)
return nil
}
func checkHooks(ctx context.Context, logger log.Logger, autofix bool) error {
if err := iterateRepositories(ctx, func(repo *repo_model.Repository) error {
results, err := repository.CheckDelegateHooks(repo.RepoPath())
if err != nil {
logger.Critical("Unable to check delegate hooks for repo %-v. ERROR: %v", repo, err)
return fmt.Errorf("Unable to check delegate hooks for repo %-v. ERROR: %w", repo, err)
}
if len(results) > 0 && autofix {
logger.Warn("Regenerated hooks for %s", repo.FullName())
if err := repository.CreateDelegateHooks(repo.RepoPath()); err != nil {
logger.Critical("Unable to recreate delegate hooks for %-v. ERROR: %v", repo, err)
return fmt.Errorf("Unable to recreate delegate hooks for %-v. ERROR: %w", repo, err)
}
}
for _, result := range results {
logger.Warn(result)
}
return nil
}); err != nil {
logger.Critical("Errors noted whilst checking delegate hooks.")
return err
}
return nil
}
func checkUserStarNum(ctx context.Context, logger log.Logger, autofix bool) error {
if autofix {
if err := models.DoctorUserStarNum(ctx); err != nil {
logger.Critical("Unable update User Stars numbers")
return err
}
logger.Info("Updated User Stars numbers.")
} else {
logger.Info("No check available for User Stars numbers (skipped)")
}
return nil
}
func checkEnablePushOptions(ctx context.Context, logger log.Logger, autofix bool) error {
numRepos := 0
numNeedUpdate := 0
if err := iterateRepositories(ctx, func(repo *repo_model.Repository) error {
numRepos++
r, err := git.OpenRepository(ctx, repo.RepoPath())
if err != nil {
return err
}
defer r.Close()
if autofix {
_, _, err := git.NewCommand(ctx, "config", "receive.advertisePushOptions", "true").RunStdString(&git.RunOpts{Dir: r.Path})
return err
}
value, _, err := git.NewCommand(ctx, "config", "receive.advertisePushOptions").RunStdString(&git.RunOpts{Dir: r.Path})
if err != nil {
return err
}
result, valid := git.ParseBool(strings.TrimSpace(value))
if !result || !valid {
numNeedUpdate++
logger.Info("%s: does not have receive.advertisePushOptions set correctly: %q", repo.FullName(), value)
}
return nil
}); err != nil {
logger.Critical("Unable to EnablePushOptions: %v", err)
return err
}
if autofix {
logger.Info("Enabled push options for %d repositories.", numRepos)
} else {
logger.Info("Checked %d repositories, %d need updates.", numRepos, numNeedUpdate)
}
return nil
}
func checkDaemonExport(ctx context.Context, logger log.Logger, autofix bool) error {
numRepos := 0
numNeedUpdate := 0
cache, err := lru.New[int64, any](512)
if err != nil {
logger.Critical("Unable to create cache: %v", err)
return err
}
if err := iterateRepositories(ctx, func(repo *repo_model.Repository) error {
numRepos++
if owner, has := cache.Get(repo.OwnerID); has {
repo.Owner = owner.(*user_model.User)
} else {
if err := repo.LoadOwner(ctx); err != nil {
return err
}
cache.Add(repo.OwnerID, repo.Owner)
}
// Create/Remove git-daemon-export-ok for git-daemon...
daemonExportFile := path.Join(repo.RepoPath(), `git-daemon-export-ok`)
isExist, err := util.IsExist(daemonExportFile)
if err != nil {
log.Error("Unable to check if %s exists. Error: %v", daemonExportFile, err)
return err
}
isPublic := !repo.IsPrivate && repo.Owner.Visibility == structs.VisibleTypePublic
if isPublic != isExist {
numNeedUpdate++
if autofix {
if !isPublic && isExist {
if err = util.Remove(daemonExportFile); err != nil {
log.Error("Failed to remove %s: %v", daemonExportFile, err)
}
} else if isPublic && !isExist {
if f, err := os.Create(daemonExportFile); err != nil {
log.Error("Failed to create %s: %v", daemonExportFile, err)
} else {
f.Close()
}
}
}
}
return nil
}); err != nil {
logger.Critical("Unable to checkDaemonExport: %v", err)
return err
}
if autofix {
logger.Info("Updated git-daemon-export-ok files for %d of %d repositories.", numNeedUpdate, numRepos)
} else {
logger.Info("Checked %d repositories, %d need updates.", numRepos, numNeedUpdate)
}
return nil
}
func checkCommitGraph(ctx context.Context, logger log.Logger, autofix bool) error {
numRepos := 0
numNeedUpdate := 0
numWritten := 0
if err := iterateRepositories(ctx, func(repo *repo_model.Repository) error {
numRepos++
commitGraphExists := func() (bool, error) {
// Check commit-graph exists
commitGraphFile := path.Join(repo.RepoPath(), `objects/info/commit-graph`)
isExist, err := util.IsExist(commitGraphFile)
if err != nil {
logger.Error("Unable to check if %s exists. Error: %v", commitGraphFile, err)
return false, err
}
if !isExist {
commitGraphsDir := path.Join(repo.RepoPath(), `objects/info/commit-graphs`)
isExist, err = util.IsExist(commitGraphsDir)
if err != nil {
logger.Error("Unable to check if %s exists. Error: %v", commitGraphsDir, err)
return false, err
}
}
return isExist, nil
}
isExist, err := commitGraphExists()
if err != nil {
return err
}
if !isExist {
numNeedUpdate++
if autofix {
if err := git.WriteCommitGraph(ctx, repo.RepoPath()); err != nil {
logger.Error("Unable to write commit-graph in %s. Error: %v", repo.FullName(), err)
return err
}
isExist, err := commitGraphExists()
if err != nil {
return err
}
if isExist {
numWritten++
logger.Info("Commit-graph written: %s", repo.FullName())
} else {
logger.Warn("No commit-graph written: %s", repo.FullName())
}
}
}
return nil
}); err != nil {
logger.Critical("Unable to checkCommitGraph: %v", err)
return err
}
if autofix {
logger.Info("Wrote commit-graph files for %d of %d repositories.", numWritten, numRepos)
} else {
logger.Info("Checked %d repositories, %d without commit-graphs.", numRepos, numNeedUpdate)
}
return nil
}
func init() {
Register(&Check{
Title: "Check if SCRIPT_TYPE is available",
Name: "script-type",
IsDefault: false,
Run: checkScriptType,
Priority: 5,
})
Register(&Check{
Title: "Check if hook files are up-to-date and executable",
Name: "hooks",
IsDefault: false,
Run: checkHooks,
Priority: 6,
})
Register(&Check{
Title: "Recalculate Stars number for all user",
Name: "recalculate-stars-number",
IsDefault: false,
Run: checkUserStarNum,
Priority: 6,
})
Register(&Check{
Title: "Check that all git repositories have receive.advertisePushOptions set to true",
Name: "enable-push-options",
IsDefault: false,
Run: checkEnablePushOptions,
Priority: 7,
})
Register(&Check{
Title: "Check git-daemon-export-ok files",
Name: "check-git-daemon-export-ok",
IsDefault: false,
Run: checkDaemonExport,
Priority: 8,
})
Register(&Check{
Title: "Check commit-graphs",
Name: "check-commit-graphs",
IsDefault: false,
Run: checkCommitGraph,
Priority: 9,
})
}

View file

@ -1,124 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package doctor
import (
"context"
"fmt"
"os"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
)
type configurationFile struct {
Name string
Path string
IsDirectory bool
Required bool
Writable bool
}
func checkConfigurationFile(logger log.Logger, autofix bool, fileOpts configurationFile) error {
logger.Info(`%-26s %q`, log.NewColoredValue(fileOpts.Name+":", log.Reset), fileOpts.Path)
fi, err := os.Stat(fileOpts.Path)
if err != nil {
if os.IsNotExist(err) && autofix && fileOpts.IsDirectory {
if err := os.MkdirAll(fileOpts.Path, 0o777); err != nil {
logger.Error(" Directory does not exist and could not be created. ERROR: %v", err)
return fmt.Errorf("Configuration directory: \"%q\" does not exist and could not be created. ERROR: %w", fileOpts.Path, err)
}
fi, err = os.Stat(fileOpts.Path)
}
}
if err != nil {
if fileOpts.Required {
logger.Error(" Is REQUIRED but is not accessible. ERROR: %v", err)
return fmt.Errorf("Configuration file \"%q\" is not accessible but is required. Error: %w", fileOpts.Path, err)
}
logger.Warn(" NOTICE: is not accessible (Error: %v)", err)
// this is a non-critical error
return nil
}
if fileOpts.IsDirectory && !fi.IsDir() {
logger.Error(" ERROR: not a directory")
return fmt.Errorf("Configuration directory \"%q\" is not a directory. Error: %w", fileOpts.Path, err)
} else if !fileOpts.IsDirectory && !fi.Mode().IsRegular() {
logger.Error(" ERROR: not a regular file")
return fmt.Errorf("Configuration file \"%q\" is not a regular file. Error: %w", fileOpts.Path, err)
} else if fileOpts.Writable {
if err := isWritableDir(fileOpts.Path); err != nil {
logger.Error(" ERROR: is required to be writable but is not writable: %v", err)
return fmt.Errorf("Configuration file \"%q\" is required to be writable but is not. Error: %w", fileOpts.Path, err)
}
}
return nil
}
func checkConfigurationFiles(ctx context.Context, logger log.Logger, autofix bool) error {
if fi, err := os.Stat(setting.CustomConf); err != nil || !fi.Mode().IsRegular() {
logger.Error("Failed to find configuration file at '%s'.", setting.CustomConf)
logger.Error("If you've never ran Gitea yet, this is normal and '%s' will be created for you on first run.", setting.CustomConf)
logger.Error("Otherwise check that you are running this command from the correct path and/or provide a `--config` parameter.")
logger.Critical("Cannot proceed without a configuration file")
return err
}
setting.MustInstalled()
configurationFiles := []configurationFile{
{"Configuration File Path", setting.CustomConf, false, true, false},
{"Repository Root Path", setting.RepoRootPath, true, true, true},
{"Data Root Path", setting.AppDataPath, true, true, true},
{"Custom File Root Path", setting.CustomPath, true, false, false},
{"Work directory", setting.AppWorkPath, true, true, false},
{"Log Root Path", setting.Log.RootPath, true, true, true},
}
if !setting.HasBuiltinBindata {
configurationFiles = append(configurationFiles, configurationFile{"Static File Root Path", setting.StaticRootPath, true, true, false})
}
numberOfErrors := 0
for _, configurationFile := range configurationFiles {
if err := checkConfigurationFile(logger, autofix, configurationFile); err != nil {
numberOfErrors++
}
}
if numberOfErrors > 0 {
logger.Critical("Please check your configuration files and try again.")
return fmt.Errorf("%d configuration files with errors", numberOfErrors)
}
return nil
}
func isWritableDir(path string) error {
// There's no platform-independent way of checking if a directory is writable
// https://stackoverflow.com/questions/20026320/how-to-tell-if-folder-exists-and-is-writable
tmpFile, err := os.CreateTemp(path, "doctors-order")
if err != nil {
return err
}
if err := os.Remove(tmpFile.Name()); err != nil {
fmt.Printf("Warning: can't remove temporary file: '%s'\n", tmpFile.Name()) //nolint:forbidigo
}
tmpFile.Close()
return nil
}
func init() {
Register(&Check{
Title: "Check paths and basic configuration",
Name: "paths",
IsDefault: true,
Run: checkConfigurationFiles,
AbortIfFailed: true,
SkipDatabaseInitialization: true,
Priority: 1,
})
}

View file

@ -1,80 +0,0 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package doctor
import (
"context"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/storage"
repo_service "code.gitea.io/gitea/services/repository"
"xorm.io/builder"
)
func handleDeleteOrphanedRepos(ctx context.Context, logger log.Logger, autofix bool) error {
test := &consistencyCheck{
Name: "Repos with no existing owner",
Counter: countOrphanedRepos,
Fixer: deleteOrphanedRepos,
FixedMessage: "Deleted all content related to orphaned repos",
}
return test.Run(ctx, logger, autofix)
}
// countOrphanedRepos count repository where user of owner_id do not exist
func countOrphanedRepos(ctx context.Context) (int64, error) {
return db.CountOrphanedObjects(ctx, "repository", "user", "repository.owner_id=`user`.id")
}
// deleteOrphanedRepos delete repository where user of owner_id do not exist
func deleteOrphanedRepos(ctx context.Context) (int64, error) {
if err := storage.Init(); err != nil {
return 0, err
}
batchSize := db.MaxBatchInsertSize("repository")
e := db.GetEngine(ctx)
var deleted int64
adminUser := &user_model.User{IsAdmin: true}
for {
select {
case <-ctx.Done():
return deleted, ctx.Err()
default:
var ids []int64
if err := e.Table("`repository`").
Join("LEFT", "`user`", "repository.owner_id=`user`.id").
Where(builder.IsNull{"`user`.id"}).
Select("`repository`.id").Limit(batchSize).Find(&ids); err != nil {
return deleted, err
}
// if we don't get ids we have deleted them all
if len(ids) == 0 {
return deleted, nil
}
for _, id := range ids {
if err := repo_service.DeleteRepositoryDirectly(ctx, adminUser, id, true); err != nil {
return deleted, err
}
deleted++
}
}
}
}
func init() {
Register(&Check{
Title: "Deleted all content related to orphaned repos",
Name: "delete-orphaned-repos",
IsDefault: false,
Run: handleDeleteOrphanedRepos,
Priority: 4,
})
}

View file

@ -1,270 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package doctor
import (
"context"
"errors"
"io/fs"
"strings"
"code.gitea.io/gitea/models/git"
"code.gitea.io/gitea/models/packages"
"code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/log"
packages_module "code.gitea.io/gitea/modules/packages"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/util"
)
type commonStorageCheckOptions struct {
storer storage.ObjectStorage
isOrphaned func(path string, obj storage.Object, stat fs.FileInfo) (bool, error)
name string
}
func commonCheckStorage(ctx context.Context, logger log.Logger, autofix bool, opts *commonStorageCheckOptions) error {
totalCount, orphanedCount := 0, 0
totalSize, orphanedSize := int64(0), int64(0)
var pathsToDelete []string
if err := opts.storer.IterateObjects("", func(p string, obj storage.Object) error {
defer obj.Close()
totalCount++
stat, err := obj.Stat()
if err != nil {
return err
}
totalSize += stat.Size()
orphaned, err := opts.isOrphaned(p, obj, stat)
if err != nil {
return err
}
if orphaned {
orphanedCount++
orphanedSize += stat.Size()
if autofix {
pathsToDelete = append(pathsToDelete, p)
}
}
return nil
}); err != nil {
logger.Error("Error whilst iterating %s storage: %v", opts.name, err)
return err
}
if orphanedCount > 0 {
if autofix {
var deletedNum int
for _, p := range pathsToDelete {
if err := opts.storer.Delete(p); err != nil {
log.Error("Error whilst deleting %s from %s storage: %v", p, opts.name, err)
} else {
deletedNum++
}
}
logger.Info("Deleted %d/%d orphaned %s(s)", deletedNum, orphanedCount, opts.name)
} else {
logger.Warn("Found %d/%d (%s/%s) orphaned %s(s)", orphanedCount, totalCount, base.FileSize(orphanedSize), base.FileSize(totalSize), opts.name)
}
} else {
logger.Info("Found %d (%s) %s(s)", totalCount, base.FileSize(totalSize), opts.name)
}
return nil
}
type checkStorageOptions struct {
All bool
Attachments bool
LFS bool
Avatars bool
RepoAvatars bool
RepoArchives bool
Packages bool
}
// checkStorage will return a doctor check function to check the requested storage types for "orphaned" stored object/files and optionally delete them
func checkStorage(opts *checkStorageOptions) func(ctx context.Context, logger log.Logger, autofix bool) error {
return func(ctx context.Context, logger log.Logger, autofix bool) error {
if err := storage.Init(); err != nil {
logger.Error("storage.Init failed: %v", err)
return err
}
if opts.Attachments || opts.All {
if err := commonCheckStorage(ctx, logger, autofix,
&commonStorageCheckOptions{
storer: storage.Attachments,
isOrphaned: func(path string, obj storage.Object, stat fs.FileInfo) (bool, error) {
exists, err := repo.ExistAttachmentsByUUID(ctx, stat.Name())
return !exists, err
},
name: "attachment",
}); err != nil {
return err
}
}
if opts.LFS || opts.All {
if !setting.LFS.StartServer {
logger.Info("LFS isn't enabled (skipped)")
return nil
}
if err := commonCheckStorage(ctx, logger, autofix,
&commonStorageCheckOptions{
storer: storage.LFS,
isOrphaned: func(path string, obj storage.Object, stat fs.FileInfo) (bool, error) {
// The oid of an LFS stored object is the name but with all the path.Separators removed
oid := strings.ReplaceAll(path, "/", "")
exists, err := git.ExistsLFSObject(ctx, oid)
return !exists, err
},
name: "LFS file",
}); err != nil {
return err
}
}
if opts.Avatars || opts.All {
if err := commonCheckStorage(ctx, logger, autofix,
&commonStorageCheckOptions{
storer: storage.Avatars,
isOrphaned: func(path string, obj storage.Object, stat fs.FileInfo) (bool, error) {
exists, err := user.ExistsWithAvatarAtStoragePath(ctx, path)
return !exists, err
},
name: "avatar",
}); err != nil {
return err
}
}
if opts.RepoAvatars || opts.All {
if err := commonCheckStorage(ctx, logger, autofix,
&commonStorageCheckOptions{
storer: storage.RepoAvatars,
isOrphaned: func(path string, obj storage.Object, stat fs.FileInfo) (bool, error) {
exists, err := repo.ExistsWithAvatarAtStoragePath(ctx, path)
return !exists, err
},
name: "repo avatar",
}); err != nil {
return err
}
}
if opts.RepoArchives || opts.All {
if err := commonCheckStorage(ctx, logger, autofix,
&commonStorageCheckOptions{
storer: storage.RepoAvatars,
isOrphaned: func(path string, obj storage.Object, stat fs.FileInfo) (bool, error) {
exists, err := repo.ExistsRepoArchiverWithStoragePath(ctx, path)
if err == nil || errors.Is(err, util.ErrInvalidArgument) {
// invalid arguments mean that the object is not a valid repo archiver and it should be removed
return !exists, nil
}
return !exists, err
},
name: "repo archive",
}); err != nil {
return err
}
}
if opts.Packages || opts.All {
if !setting.Packages.Enabled {
logger.Info("Packages isn't enabled (skipped)")
return nil
}
if err := commonCheckStorage(ctx, logger, autofix,
&commonStorageCheckOptions{
storer: storage.Packages,
isOrphaned: func(path string, obj storage.Object, stat fs.FileInfo) (bool, error) {
key, err := packages_module.RelativePathToKey(path)
if err != nil {
// If there is an error here then the relative path does not match a valid package
// Therefore it is orphaned by default
return true, nil
}
exists, err := packages.ExistPackageBlobWithSHA(ctx, string(key))
return !exists, err
},
name: "package blob",
}); err != nil {
return err
}
}
return nil
}
}
func init() {
Register(&Check{
Title: "Check if there are orphaned storage files",
Name: "storages",
IsDefault: false,
Run: checkStorage(&checkStorageOptions{All: true}),
AbortIfFailed: false,
SkipDatabaseInitialization: false,
Priority: 1,
})
Register(&Check{
Title: "Check if there are orphaned attachments in storage",
Name: "storage-attachments",
IsDefault: false,
Run: checkStorage(&checkStorageOptions{Attachments: true}),
AbortIfFailed: false,
SkipDatabaseInitialization: false,
Priority: 1,
})
Register(&Check{
Title: "Check if there are orphaned lfs files in storage",
Name: "storage-lfs",
IsDefault: false,
Run: checkStorage(&checkStorageOptions{LFS: true}),
AbortIfFailed: false,
SkipDatabaseInitialization: false,
Priority: 1,
})
Register(&Check{
Title: "Check if there are orphaned avatars in storage",
Name: "storage-avatars",
IsDefault: false,
Run: checkStorage(&checkStorageOptions{Avatars: true, RepoAvatars: true}),
AbortIfFailed: false,
SkipDatabaseInitialization: false,
Priority: 1,
})
Register(&Check{
Title: "Check if there are orphaned archives in storage",
Name: "storage-archives",
IsDefault: false,
Run: checkStorage(&checkStorageOptions{RepoArchives: true}),
AbortIfFailed: false,
SkipDatabaseInitialization: false,
Priority: 1,
})
Register(&Check{
Title: "Check if there are orphaned package blobs in storage",
Name: "storage-packages",
IsDefault: false,
Run: checkStorage(&checkStorageOptions{Packages: true}),
AbortIfFailed: false,
SkipDatabaseInitialization: false,
Priority: 1,
})
}

View file

@ -1,41 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package doctor
import (
"context"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
)
func checkUserType(ctx context.Context, logger log.Logger, autofix bool) error {
count, err := user_model.CountWrongUserType(ctx)
if err != nil {
logger.Critical("Error: %v whilst counting wrong user types")
return err
}
if count > 0 {
if autofix {
if count, err = user_model.FixWrongUserType(ctx); err != nil {
logger.Critical("Error: %v whilst fixing wrong user types")
return err
}
logger.Info("%d users with wrong type fixed", count)
} else {
logger.Warn("%d users with wrong type exist", count)
}
}
return nil
}
func init() {
Register(&Check{
Title: "Check if user with wrong type exist",
Name: "check-user-type",
IsDefault: true,
Run: checkUserType,
Priority: 3,
})
}