mirror of
https://github.com/goharbor/harbor.git
synced 2024-11-23 10:45:45 +01:00
Merge remote-tracking branch 'upstream/job-service' into sync_image
This commit is contained in:
commit
2ca82ce366
@ -103,6 +103,31 @@ create table access_log (
|
||||
FOREIGN KEY (project_id) REFERENCES project (project_id)
|
||||
);
|
||||
|
||||
create table job (
|
||||
job_id int NOT NULL AUTO_INCREMENT,
|
||||
job_type varchar(64) NOT NULL,
|
||||
status varchar(64) NOT NULL,
|
||||
options text,
|
||||
parms text,
|
||||
enabled tinyint(1) NOT NULL DEFAULT 1,
|
||||
cron_str varchar(256),
|
||||
triggered_by varchar(64),
|
||||
creation_time timestamp,
|
||||
update_time timestamp,
|
||||
PRIMARY KEY (job_id)
|
||||
);
|
||||
|
||||
create table job_log (
|
||||
log_id int NOT NULL AUTO_INCREMENT,
|
||||
job_id int NOT NULL,
|
||||
level varchar(64) NOT NULL,
|
||||
message text,
|
||||
creation_time timestamp,
|
||||
update_time timestamp,
|
||||
PRIMARY KEY (log_id),
|
||||
FOREIGN KEY (job_id) REFERENCES job (job_id)
|
||||
);
|
||||
|
||||
create table properties (
|
||||
k varchar(64) NOT NULL,
|
||||
v varchar(128) NOT NULL,
|
||||
|
85
api/job.go
Normal file
85
api/job.go
Normal file
@ -0,0 +1,85 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/vmware/harbor/dao"
|
||||
"github.com/vmware/harbor/job"
|
||||
"github.com/vmware/harbor/models"
|
||||
"github.com/vmware/harbor/utils/log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type JobAPI struct {
|
||||
BaseAPI
|
||||
}
|
||||
|
||||
func (ja *JobAPI) Post() {
|
||||
var je models.JobEntry
|
||||
ja.DecodeJSONReq(&je)
|
||||
res, err := json.Marshal(je.Options)
|
||||
if !job.RunnerExists(je.Type) {
|
||||
log.Errorf("runner for type %s is not registered", je.Type)
|
||||
ja.RenderError(http.StatusBadRequest, fmt.Sprintf("runner for type %s is not registered", je.Type))
|
||||
return
|
||||
}
|
||||
je.OptionsStr = string(res)
|
||||
if err != nil {
|
||||
log.Warningf("Error marshaling options: %v", err)
|
||||
}
|
||||
res, err = json.Marshal(je.Parms)
|
||||
je.ParmsStr = string(res)
|
||||
if err != nil {
|
||||
log.Warningf("Error marshaling parms: %v", err)
|
||||
}
|
||||
jobID, err := dao.AddJob(je)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to add job to DB, error: %v", err)
|
||||
ja.RenderError(http.StatusInternalServerError, "Failed to add job")
|
||||
return
|
||||
}
|
||||
je.ID = jobID
|
||||
log.Debugf("job Id:%d, type: %s", je.ID, je.Type)
|
||||
job.Schedule(je)
|
||||
}
|
||||
|
||||
func (ja *JobAPI) Get() {
|
||||
idStr := ja.Ctx.Input.Param(":id")
|
||||
if len(idStr) > 0 {
|
||||
jobID, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to parse job id in url: %s", idStr)
|
||||
ja.RenderError(http.StatusBadRequest, "invalid job id")
|
||||
return
|
||||
}
|
||||
je, err := dao.GetJob(jobID)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to query job from db, error: %v", err)
|
||||
ja.RenderError(http.StatusInternalServerError, "Failed to query job")
|
||||
return
|
||||
}
|
||||
if je == nil {
|
||||
log.Errorf("job does not exist, id: %d", jobID)
|
||||
ja.RenderError(http.StatusNotFound, "")
|
||||
return
|
||||
}
|
||||
logs, err := dao.GetJobLogs(jobID)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to get job logs, error: %v", err)
|
||||
ja.RenderError(http.StatusInternalServerError, "Failed to query job")
|
||||
return
|
||||
}
|
||||
je.Logs = logs
|
||||
ja.Data["json"] = je
|
||||
} else {
|
||||
jobs, err := dao.ListJobs()
|
||||
if err != nil {
|
||||
log.Errorf("Failed to list jobs, error:%v", err)
|
||||
ja.RenderError(http.StatusInternalServerError, "Failed to query job")
|
||||
}
|
||||
log.Debugf("jobs: %v", jobs)
|
||||
ja.Data["json"] = jobs
|
||||
}
|
||||
ja.ServeJSON()
|
||||
}
|
84
dao/job.go
Normal file
84
dao/job.go
Normal file
@ -0,0 +1,84 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/vmware/harbor/models"
|
||||
"github.com/vmware/harbor/utils/log"
|
||||
)
|
||||
|
||||
const (
|
||||
JobPending string = "pending"
|
||||
JobRunning string = "running"
|
||||
JobError string = "error"
|
||||
JobStopped string = "stopped"
|
||||
JobFinished string = "finished"
|
||||
)
|
||||
|
||||
func AddJob(entry models.JobEntry) (int64, error) {
|
||||
|
||||
sql := `insert into job (job_type, status, options, parms, cron_str, creation_time, update_time) values (?,"pending",?,?,?,NOW(),NOW())`
|
||||
o := orm.NewOrm()
|
||||
p, err := o.Raw(sql).Prepare()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
r, err := p.Exec(entry.Type, entry.OptionsStr, entry.ParmsStr, entry.CronStr)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
id, err := r.LastInsertId()
|
||||
return id, err
|
||||
}
|
||||
|
||||
func AddJobLog(id int64, level string, message string) error {
|
||||
sql := `insert into job_log (job_id, level, message, creation_time, update_time) values (?, ?, ?, NOW(), NOW())`
|
||||
log.Debugf("trying to add a log for job:%d", id)
|
||||
o := orm.NewOrm()
|
||||
p, err := o.Raw(sql).Prepare()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = p.Exec(id, level, message)
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateJobStatus(id int64, status string) error {
|
||||
o := orm.NewOrm()
|
||||
sql := "update job set status=?, update_time=NOW() where job_id=?"
|
||||
_, err := o.Raw(sql, status, id).Exec()
|
||||
return err
|
||||
}
|
||||
|
||||
func ListJobs() ([]models.JobEntry, error) {
|
||||
o := orm.NewOrm()
|
||||
sql := `select j.job_id, j.job_type, j.status, j.enabled, j.creation_time, j.update_time from job j`
|
||||
var res []models.JobEntry
|
||||
_, err := o.Raw(sql).QueryRows(&res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
func GetJob(id int64) (*models.JobEntry, error) {
|
||||
o := orm.NewOrm()
|
||||
sql := `select j.job_id, j.job_type, j.status, j.enabled, j.creation_time, j.update_time from job j where j.job_id = ?`
|
||||
var res []models.JobEntry
|
||||
p := make([]interface{}, 1)
|
||||
p = append(p, id)
|
||||
n, err := o.Raw(sql, p).QueryRows(&res)
|
||||
if n == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &res[0], err
|
||||
}
|
||||
|
||||
func GetJobLogs(jobID int64) ([]models.JobLog, error) {
|
||||
o := orm.NewOrm()
|
||||
var res []models.JobLog
|
||||
p := make([]interface{}, 1)
|
||||
p = append(p, jobID)
|
||||
sql := `select l.log_id, l.job_id, l.level, l.message, l.creation_time, l.update_time from job_log l where l.job_id = ?`
|
||||
_, err := o.Raw(sql, p).QueryRows(&res)
|
||||
return res, err
|
||||
}
|
13
job/imgout/parm.go
Normal file
13
job/imgout/parm.go
Normal file
@ -0,0 +1,13 @@
|
||||
package imgout
|
||||
|
||||
type ImgOutParm struct {
|
||||
Secret string `json:"secret"`
|
||||
Image string `json:"image"`
|
||||
Targets []*RegistryInfo `json:"targets"`
|
||||
}
|
||||
|
||||
type RegistryInfo struct {
|
||||
URL string `json:"url"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
82
job/imgout/runner.go
Normal file
82
job/imgout/runner.go
Normal file
@ -0,0 +1,82 @@
|
||||
package imgout
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/vmware/harbor/dao"
|
||||
"github.com/vmware/harbor/job"
|
||||
"github.com/vmware/harbor/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
jobType = "transfer_img_out"
|
||||
)
|
||||
|
||||
type Runner struct {
|
||||
job.JobSM
|
||||
Logger job.Logger
|
||||
parm ImgOutParm
|
||||
}
|
||||
|
||||
type ImgPuller struct {
|
||||
job.DummyHandler
|
||||
img string
|
||||
logger job.Logger
|
||||
}
|
||||
|
||||
func (ip ImgPuller) Enter() error {
|
||||
ip.logger.Infof("I'm pretending to pull img:%s, then sleep 10s", ip.img)
|
||||
time.Sleep(10 * time.Second)
|
||||
ip.logger.Infof("wake up from sleep....")
|
||||
return nil
|
||||
}
|
||||
|
||||
type ImgPusher struct {
|
||||
job.DummyHandler
|
||||
targetURL string
|
||||
logger job.Logger
|
||||
}
|
||||
|
||||
func (ip ImgPusher) Enter() error {
|
||||
ip.logger.Infof("I'm pretending to push img to:%s, then sleep 10s", ip.targetURL)
|
||||
time.Sleep(10 * time.Second)
|
||||
ip.logger.Infof("wake up from sleep....")
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
job.Register(jobType, Runner{})
|
||||
}
|
||||
|
||||
func (r Runner) Run(je models.JobEntry) error {
|
||||
err := r.init(je)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := []string{dao.JobRunning, "pull-img", "push-img", dao.JobFinished}
|
||||
for _, state := range path {
|
||||
err := r.EnterState(state)
|
||||
if err != nil {
|
||||
r.Logger.Errorf("Error durint transition to state: %s, error: %v", state, err)
|
||||
r.EnterState(dao.JobError)
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) init(je models.JobEntry) error {
|
||||
r.JobID = je.ID
|
||||
r.InitJobSM()
|
||||
err := json.Unmarshal([]byte(je.ParmsStr), &r.parm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Logger = job.Logger{je.ID}
|
||||
r.AddTransition(dao.JobRunning, "pull-img", ImgPuller{DummyHandler: job.DummyHandler{JobID: r.JobID}, img: r.parm.Image, logger: r.Logger})
|
||||
//only handle on target for now
|
||||
url := r.parm.Targets[0].URL
|
||||
r.AddTransition("pull-img", "push-img", ImgPusher{DummyHandler: job.DummyHandler{JobID: r.JobID}, targetURL: url, logger: r.Logger})
|
||||
r.AddTransition("push-img", dao.JobFinished, job.StatusUpdater{job.DummyHandler{JobID: r.JobID}, dao.JobFinished})
|
||||
return nil
|
||||
}
|
38
job/logger.go
Normal file
38
job/logger.go
Normal file
@ -0,0 +1,38 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/vmware/harbor/dao"
|
||||
"github.com/vmware/harbor/utils/log"
|
||||
)
|
||||
|
||||
const (
|
||||
INFO = "info"
|
||||
WARN = "warning"
|
||||
ERR = "error"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (l *Logger) Infof(format string, v ...interface{}) {
|
||||
err := dao.AddJobLog(l.ID, INFO, fmt.Sprintf(format, v...))
|
||||
if err != nil {
|
||||
log.Warningf("Failed to add job log, id: %d, error: %v", l.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) Warningf(format string, v ...interface{}) {
|
||||
err := dao.AddJobLog(l.ID, WARN, fmt.Sprintf(format, v...))
|
||||
if err != nil {
|
||||
log.Warningf("Failed to add job log, id: %d, error: %v", l.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) Errorf(format string, v ...interface{}) {
|
||||
err := dao.AddJobLog(l.ID, ERR, fmt.Sprintf(format, v...))
|
||||
if err != nil {
|
||||
log.Warningf("Failed to add job log, id: %d, error: %v", l.ID, err)
|
||||
}
|
||||
}
|
36
job/runner.go
Normal file
36
job/runner.go
Normal file
@ -0,0 +1,36 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/vmware/harbor/models"
|
||||
"github.com/vmware/harbor/utils/log"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type JobRunner interface {
|
||||
Run(je models.JobEntry) error
|
||||
}
|
||||
|
||||
var runners map[string]*JobRunner = make(map[string]*JobRunner)
|
||||
var runnerLock = &sync.Mutex{}
|
||||
|
||||
func Register(jobType string, runner JobRunner) {
|
||||
runnerLock.Lock()
|
||||
defer runnerLock.Unlock()
|
||||
runners[jobType] = &runner
|
||||
log.Debugf("runnter for job type:%s has been registered", jobType)
|
||||
}
|
||||
|
||||
func RunnerExists(jobType string) bool {
|
||||
_, ok := runners[jobType]
|
||||
return ok
|
||||
}
|
||||
|
||||
func run(je models.JobEntry) error {
|
||||
runner, ok := runners[je.Type]
|
||||
if !ok {
|
||||
return fmt.Errorf("Runner for job type: %s does not exist")
|
||||
}
|
||||
(*runner).Run(je)
|
||||
return nil
|
||||
}
|
12
job/scheduler.go
Normal file
12
job/scheduler.go
Normal file
@ -0,0 +1,12 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"github.com/vmware/harbor/models"
|
||||
"github.com/vmware/harbor/utils/log"
|
||||
)
|
||||
|
||||
func Schedule(job models.JobEntry) {
|
||||
log.Infof("job: %d will be scheduled", job.ID)
|
||||
//TODO: add support for cron string when needed.
|
||||
go run(job)
|
||||
}
|
104
job/statemachine.go
Normal file
104
job/statemachine.go
Normal file
@ -0,0 +1,104 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/vmware/harbor/dao"
|
||||
"github.com/vmware/harbor/utils/log"
|
||||
)
|
||||
|
||||
type StateHandler interface {
|
||||
Enter() error
|
||||
//Exit should be idempotent
|
||||
Exit() error
|
||||
}
|
||||
|
||||
type DummyHandler struct {
|
||||
JobID int64
|
||||
}
|
||||
|
||||
func (dh DummyHandler) Enter() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dh DummyHandler) Exit() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type StatusUpdater struct {
|
||||
DummyHandler
|
||||
State string
|
||||
}
|
||||
|
||||
func (su StatusUpdater) Enter() error {
|
||||
err := dao.UpdateJobStatus(su.JobID, su.State)
|
||||
if err != nil {
|
||||
log.Warningf("Failed to update state of job: %d, state: %s, error: %v", su.JobID, su.State, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type JobSM struct {
|
||||
JobID int64
|
||||
CurrentState string
|
||||
PreviousState string
|
||||
//The states that don't have to exist in transition map, such as "Error", "Canceled"
|
||||
ForcedStates map[string]struct{}
|
||||
Transitions map[string]map[string]struct{}
|
||||
Handlers map[string]StateHandler
|
||||
}
|
||||
|
||||
func (sm *JobSM) EnterState(s string) error {
|
||||
log.Debugf("Trying to transit from State: %s, to State: %s", sm.CurrentState, s)
|
||||
targets, ok := sm.Transitions[sm.CurrentState]
|
||||
_, exist := targets[s]
|
||||
_, isForced := sm.ForcedStates[s]
|
||||
if !exist && !isForced {
|
||||
return fmt.Errorf("Transition from %s to %s does not exist!", sm.CurrentState, s)
|
||||
}
|
||||
exitHandler, ok := sm.Handlers[sm.CurrentState]
|
||||
if ok {
|
||||
if err := exitHandler.Exit(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
log.Debugf("No handler found for state:%s, skip", sm.CurrentState)
|
||||
}
|
||||
enterHandler, ok := sm.Handlers[s]
|
||||
if ok {
|
||||
if err := enterHandler.Enter(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
log.Debugf("No handler found for state:%s, skip", s)
|
||||
}
|
||||
sm.PreviousState = sm.CurrentState
|
||||
sm.CurrentState = s
|
||||
log.Debugf("Transition succeeded, current state: %s", s)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sm *JobSM) AddTransition(from string, to string, h StateHandler) {
|
||||
_, ok := sm.Transitions[from]
|
||||
if !ok {
|
||||
sm.Transitions[from] = make(map[string]struct{})
|
||||
}
|
||||
sm.Transitions[from][to] = struct{}{}
|
||||
sm.Handlers[to] = h
|
||||
}
|
||||
|
||||
func (sm *JobSM) RemoveTransition(from string, to string) {
|
||||
_, ok := sm.Transitions[from]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
delete(sm.Transitions[from], to)
|
||||
}
|
||||
|
||||
func (sm *JobSM) InitJobSM() {
|
||||
sm.Handlers = make(map[string]StateHandler)
|
||||
sm.Transitions = make(map[string]map[string]struct{})
|
||||
sm.CurrentState = dao.JobPending
|
||||
log.Debugf("sm.Handlers: %v", sm.Handlers)
|
||||
sm.AddTransition(dao.JobPending, dao.JobRunning, StatusUpdater{DummyHandler{JobID: sm.JobID}, dao.JobRunning})
|
||||
sm.Handlers[dao.JobError] = StatusUpdater{DummyHandler{JobID: sm.JobID}, dao.JobError}
|
||||
}
|
10
jobservice/error.json
Normal file
10
jobservice/error.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"job_type": "notexist",
|
||||
"options": {
|
||||
"whatever": "whatever"
|
||||
},
|
||||
"parms": {
|
||||
"test": "test"
|
||||
},
|
||||
"cron_str": ""
|
||||
}
|
14
jobservice/main.go
Normal file
14
jobservice/main.go
Normal file
@ -0,0 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/astaxie/beego"
|
||||
"github.com/vmware/harbor/dao"
|
||||
_ "github.com/vmware/harbor/job/imgout"
|
||||
// "github.com/vmware/harbor/utils/log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dao.InitDB()
|
||||
initRouters()
|
||||
beego.Run()
|
||||
}
|
7
jobservice/my_start.sh
Executable file
7
jobservice/my_start.sh
Executable file
@ -0,0 +1,7 @@
|
||||
export MYSQL_HOST=127.0.0.1
|
||||
export MYSQL_PORT=3306
|
||||
export MYSQL_USR=root
|
||||
export MYSQL_PWD=root123
|
||||
export LOG_LEVEL=debug
|
||||
|
||||
./jobservice
|
11
jobservice/router.go
Normal file
11
jobservice/router.go
Normal file
@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/vmware/harbor/api"
|
||||
|
||||
"github.com/astaxie/beego"
|
||||
)
|
||||
|
||||
func initRouters() {
|
||||
beego.Router("/api/jobs/?:id", &api.JobAPI{})
|
||||
}
|
2
jobservice/start_db.sh
Executable file
2
jobservice/start_db.sh
Executable file
@ -0,0 +1,2 @@
|
||||
#export MYQL_ROOT_PASSWORD=root123
|
||||
docker run --name harbor_mysql -d -e MYSQL_ROOT_PASSWORD=root123 -p 3306:3306 -v /devdata/database:/var/lib/mysql harbor/mysql:dev
|
17
jobservice/test.json
Normal file
17
jobservice/test.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"job_type": "transfer_img_out",
|
||||
"options": {
|
||||
"whatever": "whatever"
|
||||
},
|
||||
"parms": {
|
||||
"secret": "mysecret",
|
||||
"image": "ubuntu",
|
||||
"targets": [{
|
||||
"url": "127.0.0.1:5000",
|
||||
"username": "admin",
|
||||
"password": "admin"
|
||||
}]
|
||||
|
||||
},
|
||||
"cron_str": ""
|
||||
}
|
30
models/job.go
Normal file
30
models/job.go
Normal file
@ -0,0 +1,30 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type JobEntry struct {
|
||||
ID int64 `orm:"column(job_id)" json:"job_id"`
|
||||
Type string `orm:"column(job_type)" json:"job_type"`
|
||||
OptionsStr string `orm:"column(options)"`
|
||||
ParmsStr string `orm:"column(parms)"`
|
||||
Status string `orm:"column(status)" json:"status"`
|
||||
Options map[string]interface{} `json:"options"`
|
||||
Parms map[string]interface{} `json:"parms"`
|
||||
Enabled int `orm:"column(enabled)" json:"enabled"`
|
||||
CronStr string `orm:"column(cron_str)" json:"cron_str"`
|
||||
TriggeredBy string `orm:"column(triggered_by)" json:"triggered_by"`
|
||||
CreationTime time.Time `orm:"creation_time" json:"creation_time"`
|
||||
UpdateTime time.Time `orm:"update_time" json:"update_time"`
|
||||
Logs []JobLog `json:"logs"`
|
||||
}
|
||||
|
||||
type JobLog struct {
|
||||
ID int64 `orm:"column(log_id)" json:"log_id"`
|
||||
JobID int64 `orm:"column(job_id)" json:"job_id"`
|
||||
Level string `orm:"column(level)" json:"level"`
|
||||
Message string `orm:"column(message)" json:"message"`
|
||||
CreationTime time.Time `orm:"creation_time" json:"creation_time"`
|
||||
UpdateTime time.Time `orm:"update_time" json:"update_time"`
|
||||
}
|
Loading…
Reference in New Issue
Block a user