Compare commits
14 Commits
docker-hea
...
main
Author | SHA1 | Date | |
---|---|---|---|
3be1bd6ee0 | |||
abd272635e | |||
2229437a7f | |||
4b16dea34e | |||
7b313b8f9b | |||
bea338c27a | |||
1d0d6b3fe6 | |||
985572d737 | |||
cff06cd1c6 | |||
90cd0ec9e0 | |||
a0db27be1a | |||
cddc290ee0 | |||
d049228980 | |||
390074e048 |
@ -4,7 +4,7 @@ name: test
|
||||
|
||||
steps:
|
||||
- name: test
|
||||
image: golang:1.20
|
||||
image: golang:1.21
|
||||
environment:
|
||||
VERSION: ${DRONE_TAG:-${DRONE_COMMIT}}
|
||||
commands:
|
||||
@ -13,7 +13,7 @@ steps:
|
||||
- make test
|
||||
|
||||
- name: check
|
||||
image: iamthefij/drone-pre-commit:personal
|
||||
image: iamthefij/drone-pre-commit@sha256:30fa17489b86d7a4c3ad9c3ce2e152c25d82b8671e5609d322c6cae0baed89cd
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
@ -32,7 +32,7 @@ trigger:
|
||||
|
||||
steps:
|
||||
- name: build all binaries
|
||||
image: golang:1.17
|
||||
image: golang:1.21
|
||||
environment:
|
||||
VERSION: ${DRONE_TAG:-${DRONE_COMMIT}}
|
||||
commands:
|
||||
|
@ -31,10 +31,8 @@ linters:
|
||||
- gocognit
|
||||
- goconst
|
||||
- gocritic
|
||||
# - gocyclo # Using cyclop
|
||||
- godot
|
||||
# - goerr113 # Using errorlint
|
||||
- gofmt
|
||||
- gofumpt
|
||||
- goheader
|
||||
- goimports
|
||||
- gomnd
|
||||
@ -44,7 +42,6 @@ linters:
|
||||
- gosec
|
||||
- grouper
|
||||
- importas
|
||||
# - ireturn
|
||||
- lll
|
||||
- maintidx
|
||||
- makezero
|
||||
@ -59,11 +56,9 @@ linters:
|
||||
- paralleltest
|
||||
- prealloc
|
||||
- predeclared
|
||||
# - promlinter # Not common enough
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- sqlclosecheck
|
||||
# - stylecheck # Using revive
|
||||
- tagliatelle
|
||||
- tenv
|
||||
- testpackage
|
||||
@ -71,27 +66,11 @@ linters:
|
||||
- tparallel
|
||||
- unconvert
|
||||
- unparam
|
||||
- varnamelen
|
||||
- wastedassign
|
||||
- whitespace
|
||||
- wrapcheck
|
||||
- wsl
|
||||
|
||||
disable:
|
||||
- gochecknoglobals
|
||||
- godox
|
||||
- forbidigo
|
||||
# Deprecated
|
||||
- golint
|
||||
- interfacer
|
||||
- maligned
|
||||
- scopelint
|
||||
- ifshort
|
||||
- varcheck
|
||||
- structcheck
|
||||
- deadcode
|
||||
- exhaustivestruct
|
||||
|
||||
linters-settings:
|
||||
gomnd:
|
||||
settings:
|
||||
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v3.4.0
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
- id: check-yaml
|
||||
@ -11,10 +11,8 @@ repos:
|
||||
- id: end-of-file-fixer
|
||||
- id: check-merge-conflict
|
||||
- repo: https://github.com/dnephin/pre-commit-golang
|
||||
rev: v0.4.0
|
||||
rev: v0.5.1
|
||||
hooks:
|
||||
- id: go-fmt
|
||||
- id: go-imports
|
||||
- id: golangci-lint
|
||||
args:
|
||||
- --timeout=3m
|
||||
|
@ -11,7 +11,7 @@ RUN apk add --no-cache \
|
||||
redis~=7 \
|
||||
restic~=0.15 \
|
||||
sqlite~=3 \
|
||||
tzdata~=2023c \
|
||||
tzdata~=2023 \
|
||||
;
|
||||
|
||||
ARG TARGETOS
|
||||
|
6
job.go
6
job.go
@ -280,8 +280,10 @@ func (j Job) Run() {
|
||||
result.LastError = err
|
||||
} else {
|
||||
Metrics.SnapshotCurrentCount.WithLabelValues(j.Name).Set(float64(len(snapshots)))
|
||||
latestSnapshot := snapshots[len(snapshots)-1]
|
||||
Metrics.SnapshotLatestTime.WithLabelValues(j.Name).Set(float64(latestSnapshot.Time.Unix()))
|
||||
if len(snapshots) > 0 {
|
||||
latestSnapshot := snapshots[len(snapshots)-1]
|
||||
Metrics.SnapshotLatestTime.WithLabelValues(j.Name).Set(float64(latestSnapshot.Time.Unix()))
|
||||
}
|
||||
}
|
||||
|
||||
if result.Success {
|
||||
|
17
job_test.go
17
job_test.go
@ -148,7 +148,9 @@ func TestJobValidation(t *testing.T) {
|
||||
Name: "Test job",
|
||||
Schedule: "@daily",
|
||||
Config: ValidResticConfig(),
|
||||
Tasks: []main.JobTask{{}},
|
||||
Tasks: []main.JobTask{
|
||||
{}, //nolint:exhaustruct
|
||||
},
|
||||
Backup: main.BackupFilesTask{Paths: []string{"/test"}}, //nolint:exhaustruct
|
||||
Forget: nil,
|
||||
MySQL: []main.JobTaskMySQL{},
|
||||
@ -166,7 +168,9 @@ func TestJobValidation(t *testing.T) {
|
||||
Tasks: []main.JobTask{},
|
||||
Backup: main.BackupFilesTask{Paths: []string{"/test"}}, //nolint:exhaustruct
|
||||
Forget: nil,
|
||||
MySQL: []main.JobTaskMySQL{{}},
|
||||
MySQL: []main.JobTaskMySQL{
|
||||
{}, //nolint:exhaustruct
|
||||
},
|
||||
Postgres: []main.JobTaskPostgres{},
|
||||
Sqlite: []main.JobTaskSqlite{},
|
||||
},
|
||||
@ -183,7 +187,9 @@ func TestJobValidation(t *testing.T) {
|
||||
Forget: nil,
|
||||
MySQL: []main.JobTaskMySQL{},
|
||||
Postgres: []main.JobTaskPostgres{},
|
||||
Sqlite: []main.JobTaskSqlite{{}},
|
||||
Sqlite: []main.JobTaskSqlite{
|
||||
{}, //nolint:exhaustruct
|
||||
},
|
||||
},
|
||||
expectedErr: main.ErrMissingField,
|
||||
},
|
||||
@ -222,6 +228,7 @@ func TestConfigValidation(t *testing.T) {
|
||||
Config: ValidResticConfig(),
|
||||
Tasks: []main.JobTask{},
|
||||
Backup: main.BackupFilesTask{Paths: []string{"/test"}}, //nolint:exhaustruct
|
||||
Forget: nil,
|
||||
MySQL: []main.JobTaskMySQL{},
|
||||
Postgres: []main.JobTaskPostgres{},
|
||||
Sqlite: []main.JobTaskSqlite{},
|
||||
@ -239,6 +246,7 @@ func TestConfigValidation(t *testing.T) {
|
||||
Config: nil,
|
||||
Tasks: []main.JobTask{},
|
||||
Backup: main.BackupFilesTask{Paths: []string{"/test"}}, //nolint:exhaustruct
|
||||
Forget: nil,
|
||||
MySQL: []main.JobTaskMySQL{},
|
||||
Postgres: []main.JobTaskPostgres{},
|
||||
Sqlite: []main.JobTaskSqlite{},
|
||||
@ -286,7 +294,8 @@ func TestConfigValidation(t *testing.T) {
|
||||
MySQL: []main.JobTaskMySQL{},
|
||||
Postgres: []main.JobTaskPostgres{},
|
||||
Sqlite: []main.JobTaskSqlite{},
|
||||
}}},
|
||||
}},
|
||||
},
|
||||
expectedErr: main.ErrMissingField,
|
||||
},
|
||||
}
|
||||
|
50
main.go
50
main.go
@ -29,8 +29,12 @@ func ParseConfig(path string) ([]Job, error) {
|
||||
Functions: map[string]function.Function{
|
||||
"env": function.New(&function.Spec{
|
||||
Params: []function.Parameter{{
|
||||
Name: "var",
|
||||
Type: cty.String,
|
||||
Name: "var",
|
||||
Type: cty.String,
|
||||
AllowNull: false,
|
||||
AllowUnknown: false,
|
||||
AllowDynamicType: false,
|
||||
AllowMarked: false,
|
||||
}},
|
||||
VarParam: nil,
|
||||
Type: function.StaticReturnType(cty.String),
|
||||
@ -40,8 +44,12 @@ func ParseConfig(path string) ([]Job, error) {
|
||||
}),
|
||||
"readfile": function.New(&function.Spec{
|
||||
Params: []function.Parameter{{
|
||||
Name: "path",
|
||||
Type: cty.String,
|
||||
Name: "path",
|
||||
Type: cty.String,
|
||||
AllowNull: false,
|
||||
AllowUnknown: false,
|
||||
AllowDynamicType: false,
|
||||
AllowMarked: false,
|
||||
}},
|
||||
VarParam: nil,
|
||||
Type: function.StaticReturnType(cty.String),
|
||||
@ -181,10 +189,32 @@ func runRestoreJobs(jobs []Job, names string, snapshot string) error {
|
||||
return filterJobErr
|
||||
}
|
||||
|
||||
func runUnlockJobs(jobs []Job, names string) error {
|
||||
if names == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
namesSlice := strings.Split(names, ",")
|
||||
|
||||
if len(namesSlice) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
jobs, filterJobErr := FilterJobs(jobs, namesSlice)
|
||||
for _, job := range jobs {
|
||||
if err := job.NewRestic().Unlock(UnlockOpts{RemoveAll: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return filterJobErr
|
||||
}
|
||||
|
||||
type Flags struct {
|
||||
showVersion bool
|
||||
backup string
|
||||
restore string
|
||||
unlock string
|
||||
restoreSnapshot string
|
||||
once bool
|
||||
healthCheckAddr string
|
||||
@ -196,6 +226,7 @@ func readFlags() Flags {
|
||||
flag.BoolVar(&flags.showVersion, "version", false, "Display the version and exit")
|
||||
flag.StringVar(&flags.backup, "backup", "", "Run backup jobs now. Names are comma separated. `all` will run all.")
|
||||
flag.StringVar(&flags.restore, "restore", "", "Run restore jobs now. Names are comma separated. `all` will run all.")
|
||||
flag.StringVar(&flags.unlock, "unlock", "", "Unlock job repos now. Names are comma separated. `all` will run all.")
|
||||
flag.BoolVar(&flags.once, "once", false, "Run jobs specified using -backup and -restore once and exit")
|
||||
flag.StringVar(&flags.healthCheckAddr, "addr", "0.0.0.0:8080", "address to bind health check API")
|
||||
flag.StringVar(&flags.metricsPushGateway, "push-gateway", "", "url of push gateway service for batch runs (optional)")
|
||||
@ -206,7 +237,12 @@ func readFlags() Flags {
|
||||
return flags
|
||||
}
|
||||
|
||||
func runSpecifiedJobs(jobs []Job, backupJobs, restoreJobs, snapshot string) error {
|
||||
func runSpecifiedJobs(jobs []Job, backupJobs, restoreJobs, unlockJobs, snapshot string) error {
|
||||
// Run specified job unlocks
|
||||
if err := runUnlockJobs(jobs, unlockJobs); err != nil {
|
||||
return fmt.Errorf("Failed running unlock for jobs: %w", err)
|
||||
}
|
||||
|
||||
// Run specified backup jobs
|
||||
if err := runBackupJobs(jobs, backupJobs); err != nil {
|
||||
return fmt.Errorf("Failed running backup jobs: %w", err)
|
||||
@ -222,6 +258,8 @@ func runSpecifiedJobs(jobs []Job, backupJobs, restoreJobs, snapshot string) erro
|
||||
|
||||
func maybePushMetrics(metricsPushGateway string) error {
|
||||
if metricsPushGateway != "" {
|
||||
fmt.Println("Pushing metrics to push gateway")
|
||||
|
||||
if err := Metrics.PushToGateway(metricsPushGateway); err != nil {
|
||||
return fmt.Errorf("Failed pushing metrics after jobs run: %w", err)
|
||||
}
|
||||
@ -253,7 +291,7 @@ func main() {
|
||||
log.Fatalf("Failed to read jobs from files: %v", err)
|
||||
}
|
||||
|
||||
if err := runSpecifiedJobs(jobs, flags.backup, flags.restore, flags.restoreSnapshot); err != nil {
|
||||
if err := runSpecifiedJobs(jobs, flags.backup, flags.restore, flags.unlock, flags.restoreSnapshot); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
@ -31,7 +31,6 @@ func TestReadJobs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
jobs, err := main.ReadJobs([]string{"./test/sample.hcl"})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error reading jobs: %v", err)
|
||||
}
|
||||
|
@ -19,7 +19,6 @@ func (m ResticMetrics) PushToGateway(url string) error {
|
||||
err := push.New(url, "batch").
|
||||
Gatherer(m.Registry).
|
||||
Add()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("error pushing to registry %s: %w", url, err)
|
||||
}
|
||||
|
51
restic.go
51
restic.go
@ -11,18 +11,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrRestic = errors.New("restic error")
|
||||
var ErrRepoNotFound = errors.New("repository not found or uninitialized")
|
||||
|
||||
func lineIn(needle string, haystack []string) bool {
|
||||
for _, line := range haystack {
|
||||
if line == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
var (
|
||||
ErrRestic = errors.New("restic error")
|
||||
ErrRepoNotFound = errors.Join(errors.New("repository not found or uninitialized"), ErrRestic)
|
||||
)
|
||||
|
||||
func maybeAddArgString(args []string, name, value string) []string {
|
||||
if value != "" {
|
||||
@ -56,22 +48,41 @@ func maybeAddArgsList(args []string, name string, value []string) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
// CommandOptions interface dictates a ToArgs() method should return each commandline arg as a string slice.
|
||||
type CommandOptions interface {
|
||||
// ToArgs returns the structs arguments as a slice of strings.
|
||||
ToArgs() []string
|
||||
}
|
||||
|
||||
// GenericOpts allows passing an arbitrary string slice as a set of command line options compatible with CommandOptions.
|
||||
type GenericOpts []string
|
||||
|
||||
// ToArgs returns the structs arguments as a slice of strings.
|
||||
func (o GenericOpts) ToArgs() []string {
|
||||
return o
|
||||
}
|
||||
|
||||
// NoOpts is a struct that fulfils the CommandOptions interface but provides no arguments.
|
||||
type NoOpts struct{}
|
||||
|
||||
// ToArgs returns the structs arguments as a slice of strings.
|
||||
func (NoOpts) ToArgs() []string {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// UnlockOpts holds optional arguments for unlock command.
|
||||
type UnlockOpts struct {
|
||||
RemoveAll bool `hcl:"RemoveAll,optional"`
|
||||
}
|
||||
|
||||
// ToArgs returns the structs arguments as a slice of strings.
|
||||
func (uo UnlockOpts) ToArgs() (args []string) {
|
||||
args = maybeAddArgBool(args, "--remove-all", uo.RemoveAll)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// BackupOpts holds optional arguments for the Restic backup command.
|
||||
type BackupOpts struct {
|
||||
Exclude []string `hcl:"Exclude,optional"`
|
||||
Include []string `hcl:"Include,optional"`
|
||||
@ -79,6 +90,7 @@ type BackupOpts struct {
|
||||
Host string `hcl:"Host,optional"`
|
||||
}
|
||||
|
||||
// ToArgs returns the structs arguments as a slice of strings.
|
||||
func (bo BackupOpts) ToArgs() (args []string) {
|
||||
args = maybeAddArgsList(args, "--exclude", bo.Exclude)
|
||||
args = maybeAddArgsList(args, "--include", bo.Include)
|
||||
@ -98,6 +110,7 @@ type RestoreOpts struct {
|
||||
Verify bool `hcl:"Verify,optional"`
|
||||
}
|
||||
|
||||
// ToArgs returns the structs arguments as a slice of strings.
|
||||
func (ro RestoreOpts) ToArgs() (args []string) {
|
||||
args = maybeAddArgsList(args, "--exclude", ro.Exclude)
|
||||
args = maybeAddArgsList(args, "--include", ro.Include)
|
||||
@ -137,6 +150,7 @@ type ForgetOpts struct {
|
||||
Prune bool `hcl:"Prune,optional"`
|
||||
}
|
||||
|
||||
// ToArgs returns the structs arguments as a slice of strings.
|
||||
func (fo ForgetOpts) ToArgs() (args []string) {
|
||||
args = maybeAddArgInt(args, "--keep-last", fo.KeepLast)
|
||||
args = maybeAddArgInt(args, "--keep-hourly", fo.KeepHourly)
|
||||
@ -193,13 +207,15 @@ type ResticGlobalOpts struct {
|
||||
TLSClientCertFile string `hcl:"TlsClientCertFile,optional"`
|
||||
LimitDownload int `hcl:"LimitDownload,optional"`
|
||||
LimitUpload int `hcl:"LimitUpload,optional"`
|
||||
Options map[string]string `hcl:"Options,optional"`
|
||||
VerboseLevel int `hcl:"VerboseLevel,optional"`
|
||||
Options map[string]string `hcl:"Options,optional"`
|
||||
CleanupCache bool `hcl:"CleanupCache,optional"`
|
||||
InsecureTLS bool `hcl:"InsecureTls,optional"`
|
||||
NoCache bool `hcl:"NoCache,optional"`
|
||||
NoLock bool `hcl:"NoLock,optional"`
|
||||
}
|
||||
|
||||
// ToArgs returns the structs arguments as a slice of strings.
|
||||
func (glo ResticGlobalOpts) ToArgs() (args []string) {
|
||||
args = maybeAddArgString(args, "--cacert", glo.CaCertFile)
|
||||
args = maybeAddArgString(args, "--cache-dir", glo.CacheDir)
|
||||
@ -209,6 +225,7 @@ func (glo ResticGlobalOpts) ToArgs() (args []string) {
|
||||
args = maybeAddArgInt(args, "--limit-upload", glo.LimitUpload)
|
||||
args = maybeAddArgInt(args, "--verbose", glo.VerboseLevel)
|
||||
args = maybeAddArgBool(args, "--cleanup-cache", glo.CleanupCache)
|
||||
args = maybeAddArgBool(args, "--insecure-tls", glo.InsecureTLS)
|
||||
args = maybeAddArgBool(args, "--no-cache", glo.NoCache)
|
||||
args = maybeAddArgBool(args, "--no-lock", glo.NoLock)
|
||||
|
||||
@ -301,7 +318,7 @@ func (rcmd Restic) RunRestic(
|
||||
responseErr = ErrRepoNotFound
|
||||
}
|
||||
|
||||
return output, NewResticError(command, output.AllLines(), responseErr)
|
||||
return output, NewResticError(command, output.AllLines(), errors.Join(err, responseErr))
|
||||
}
|
||||
|
||||
return output, nil
|
||||
@ -331,6 +348,12 @@ func (rcmd Restic) Check() error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (rcmd Restic) Unlock(unlockOpts UnlockOpts) error {
|
||||
_, err := rcmd.RunRestic("unlock", unlockOpts)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
UID int `json:"uid"`
|
||||
GID int `json:"gid"`
|
||||
|
@ -32,6 +32,7 @@ func TestGlobalOptions(t *testing.T) {
|
||||
LimitUpload: 1,
|
||||
VerboseLevel: 1,
|
||||
CleanupCache: true,
|
||||
InsecureTLS: true,
|
||||
NoCache: true,
|
||||
NoLock: true,
|
||||
Options: map[string]string{
|
||||
@ -48,6 +49,7 @@ func TestGlobalOptions(t *testing.T) {
|
||||
"--limit-upload", "1",
|
||||
"--verbose", "1",
|
||||
"--cleanup-cache",
|
||||
"--insecure-tls",
|
||||
"--no-cache",
|
||||
"--no-lock",
|
||||
"--option", "key='a long value'",
|
||||
@ -150,6 +152,20 @@ func TestForgetOpts(t *testing.T) {
|
||||
AssertEqual(t, "args didn't match", expected, args)
|
||||
}
|
||||
|
||||
func TestUnlockOpts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
args := main.UnlockOpts{
|
||||
RemoveAll: true,
|
||||
}.ToArgs()
|
||||
|
||||
expected := []string{
|
||||
"--remove-all",
|
||||
}
|
||||
|
||||
AssertEqual(t, "args didn't match", expected, args)
|
||||
}
|
||||
|
||||
func TestBuildEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@ -221,7 +237,7 @@ func TestResticInterface(t *testing.T) {
|
||||
}
|
||||
|
||||
// Write test file to the data dir
|
||||
err := os.WriteFile(dataFile, []byte("testing"), 0644)
|
||||
err := os.WriteFile(dataFile, []byte("testing"), 0o644)
|
||||
AssertEqualFail(t, "unexpected error writing to test file", nil, err)
|
||||
|
||||
// Make sure no existing repo is found
|
||||
@ -281,7 +297,7 @@ func TestResticInterface(t *testing.T) {
|
||||
AssertEqualFail(t, "unexpected error checking repo", nil, err)
|
||||
|
||||
// Change the data file
|
||||
err = os.WriteFile(dataFile, []byte("unexpected"), 0644)
|
||||
err = os.WriteFile(dataFile, []byte("unexpected"), 0o644)
|
||||
AssertEqualFail(t, "unexpected error writing to test file", nil, err)
|
||||
|
||||
// Check that data wrote
|
||||
@ -297,4 +313,8 @@ func TestResticInterface(t *testing.T) {
|
||||
value, err = os.ReadFile(restoredDataFile)
|
||||
AssertEqualFail(t, "unexpected error reading from test file", nil, err)
|
||||
AssertEqualFail(t, "incorrect value in test file", "testing", string(value))
|
||||
|
||||
// Try to unlock the repo (repo shouldn't really be locked, but this should still run without error
|
||||
err = restic.Unlock(main.UnlockOpts{}) //nolint:exhaustruct
|
||||
AssertEqualFail(t, "unexpected error unlocking repo", nil, err)
|
||||
}
|
||||
|
@ -13,8 +13,10 @@ import (
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
var jobResultsLock = sync.Mutex{}
|
||||
var jobResults = map[string]JobResult{}
|
||||
var (
|
||||
jobResultsLock = sync.Mutex{}
|
||||
jobResults = map[string]JobResult{}
|
||||
)
|
||||
|
||||
type JobResult struct {
|
||||
JobName string
|
||||
@ -113,6 +115,8 @@ func ScheduleAndRunJobs(jobs []Job) error {
|
||||
defer func() {
|
||||
ctx := scheduler.Stop()
|
||||
<-ctx.Done()
|
||||
|
||||
fmt.Println("All jobs successfully stopped")
|
||||
}()
|
||||
|
||||
return nil
|
||||
|
Loading…
Reference in New Issue
Block a user