add admin job api (#5344)

It supports Harbor admin to trigger job either manual or
schedule. The job will be populated to job service to execute. 
The api includes:
1. POST /api/system/gc
2, GET /api/system/gc/:id 
3, GET /api/system/gc/:id/log
4, PUT/GET/POST /api/system/gc/schedule
This commit is contained in:
Yan 2018-07-20 19:22:37 +08:00 committed by GitHub
parent bbb190327d
commit efdb57548f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1090 additions and 37 deletions

View File

@ -336,7 +336,25 @@ the resource_name is the name of image when the resource_type is i
);
CREATE TRIGGER harbor_resource_label_update_time_at_modtime BEFORE UPDATE ON harbor_resource_label FOR EACH ROW EXECUTE PROCEDURE update_update_time_at_column();
create table admin_job (
id SERIAL NOT NULL,
job_name varchar(64) NOT NULL,
job_kind varchar(64) NOT NULL,
cron_str varchar(256),
status varchar(64) NOT NULL,
job_uuid varchar(64),
creation_time timestamp default 'now'::timestamp,
update_time timestamp default 'now'::timestamp,
deleted boolean DEFAULT false NOT NULL,
PRIMARY KEY(id)
);
CREATE TRIGGER admin_job_update_time_at_modtime BEFORE UPDATE ON admin_job FOR EACH ROW EXECUTE PROCEDURE update_update_time_at_column();
CREATE INDEX admin_job_status ON admin_job (status);
CREATE INDEX admin_job_uuid ON admin_job (job_uuid);
CREATE TABLE IF NOT EXISTS alembic_version (
version_num varchar(32) NOT NULL
);

View File

@ -72,6 +72,12 @@ func (b *BaseAPI) HandleBadRequest(text string) {
b.RenderError(http.StatusBadRequest, text)
}
// HandleStatusPreconditionFailed ...
func (b *BaseAPI) HandleStatusPreconditionFailed(text string) {
log.Info(text)
b.RenderError(http.StatusPreconditionFailed, text)
}
// HandleConflict ...
func (b *BaseAPI) HandleConflict(text ...string) {
msg := ""

135
src/common/dao/admin_job.go Normal file
View File

@ -0,0 +1,135 @@
// Copyright (c) 2017 VMware, Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package dao
import (
"time"
"github.com/astaxie/beego/orm"
"github.com/vmware/harbor/src/common/models"
"github.com/vmware/harbor/src/common/utils/log"
)
// AddAdminJob ...
func AddAdminJob(job *models.AdminJob) (int64, error) {
o := GetOrmer()
if len(job.Status) == 0 {
job.Status = models.JobPending
}
sql := "insert into admin_job (job_name, job_kind, status, job_uuid, cron_str, creation_time, update_time) values (?, ?, ?, ?, ?, ?, ?) RETURNING id"
var id int64
now := time.Now()
err := o.Raw(sql, job.Name, job.Kind, job.Status, job.UUID, job.Cron, now, now).QueryRow(&id)
if err != nil {
return 0, err
}
return id, nil
}
// GetAdminJob ...
func GetAdminJob(id int64) (*models.AdminJob, error) {
o := GetOrmer()
aj := models.AdminJob{ID: id}
err := o.Read(&aj)
if err == orm.ErrNoRows {
return nil, err
}
return &aj, nil
}
// DeleteAdminJob ...
func DeleteAdminJob(id int64) error {
o := GetOrmer()
_, err := o.Raw(`update admin_job
set deleted = true where id = ?`, id).Exec()
return err
}
// UpdateAdminJobStatus ...
func UpdateAdminJobStatus(id int64, status string) error {
o := GetOrmer()
j := models.AdminJob{
ID: id,
Status: status,
UpdateTime: time.Now(),
}
n, err := o.Update(&j, "Status", "UpdateTime")
if n == 0 {
log.Warningf("no records are updated when updating admin job %d", id)
}
return err
}
// SetAdminJobUUID ...
func SetAdminJobUUID(id int64, uuid string) error {
o := GetOrmer()
j := models.AdminJob{
ID: id,
UUID: uuid,
}
n, err := o.Update(&j, "UUID")
if n == 0 {
log.Warningf("no records are updated when updating admin job %d", id)
}
return err
}
// GetTop10AdminJobs ...
func GetTop10AdminJobs() ([]*models.AdminJob, error) {
sql := `select * from admin_job
where deleted = false order by update_time limit 10`
jobs := []*models.AdminJob{}
_, err := GetOrmer().Raw(sql).QueryRows(&jobs)
return jobs, err
}
// GetAdminJobs get admin jobs bases on query conditions
func GetAdminJobs(query *models.AdminJobQuery) ([]*models.AdminJob, error) {
adjs := []*models.AdminJob{}
qs := adminQueryConditions(query)
if query.Size > 0 {
qs = qs.Limit(query.Size)
if query.Page > 0 {
qs = qs.Offset((query.Page - 1) * query.Size)
}
}
_, err := qs.All(&adjs)
return adjs, err
}
// adminQueryConditions
func adminQueryConditions(query *models.AdminJobQuery) orm.QuerySeter {
qs := GetOrmer().QueryTable(&models.AdminJob{})
if query.ID > 0 {
qs = qs.Filter("ID", query.ID)
}
if len(query.Kind) > 0 {
qs = qs.Filter("Kind", query.Kind)
}
if len(query.Name) > 0 {
qs = qs.Filter("Name", query.Name)
}
if len(query.Status) > 0 {
qs = qs.Filter("Status", query.Status)
}
if len(query.UUID) > 0 {
qs = qs.Filter("UUID", query.UUID)
}
qs = qs.Filter("Deleted", false)
return qs
}

View File

@ -0,0 +1,75 @@
// Copyright (c) 2017 VMware, Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package dao
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/vmware/harbor/src/common/models"
)
func TestAddAdminJob(t *testing.T) {
job := &models.AdminJob{
Name: "job",
Kind: "jobKind",
}
job0 := &models.AdminJob{
Name: "GC",
Kind: "testKind",
}
// add
id, err := AddAdminJob(job0)
require.Nil(t, err)
job0.ID = id
// get
job1, err := GetAdminJob(id)
require.Nil(t, err)
assert.Equal(t, job1.ID, job0.ID)
assert.Equal(t, job1.Name, job0.Name)
// update status
err = UpdateAdminJobStatus(id, "testStatus")
require.Nil(t, err)
job2, err := GetAdminJob(id)
assert.Equal(t, job2.Status, "testStatus")
// set uuid
err = SetAdminJobUUID(id, "f5ef34f4cb3588d663176132")
require.Nil(t, err)
job3, err := GetAdminJob(id)
require.Nil(t, err)
assert.Equal(t, job3.UUID, "f5ef34f4cb3588d663176132")
// get admin jobs
_, err = AddAdminJob(job)
require.Nil(t, err)
query := &models.AdminJobQuery{
Name: "job",
}
jobs, err := GetAdminJobs(query)
assert.Equal(t, len(jobs), 1)
// get top 10
_, err = AddAdminJob(job)
require.Nil(t, err)
jobs, _ = GetTop10AdminJobs()
assert.Equal(t, len(jobs), 3)
}

View File

@ -0,0 +1,53 @@
// Copyright (c) 2017 VMware, Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package models
import (
"time"
)
const (
//AdminJobTable is table name for admin job
AdminJobTable = "admin_job"
)
// AdminJob ...
type AdminJob struct {
ID int64 `orm:"pk;auto;column(id)" json:"id"`
Name string `orm:"column(job_name)" json:"job_name"`
Kind string `orm:"column(job_kind)" json:"job_kind"`
Cron string `orm:"column(cron_str)" json:"cron_str"`
Status string `orm:"column(status)" json:"job_status"`
UUID string `orm:"column(job_uuid)" json:"-"`
Deleted bool `orm:"column(deleted)" json:"deleted"`
CreationTime time.Time `orm:"column(creation_time);auto_now_add" json:"creation_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now" json:"update_time"`
}
//TableName is required by by beego orm to map AdminJob to table AdminJob
func (a *AdminJob) TableName() string {
return AdminJobTable
}
// AdminJobQuery : query parameters for adminjob
type AdminJobQuery struct {
ID int64
Name string
Kind string
Status string
UUID string
Deleted bool
Pagination
}

View File

@ -35,5 +35,6 @@ func init() {
new(ConfigEntry),
new(Label),
new(ResourceLabel),
new(UserGroup))
new(UserGroup),
new(AdminJob))
}

View File

@ -199,3 +199,13 @@ func SafeCastFloat64(value interface{}) float64 {
}
return 0
}
// ParseOfftime ...
func ParseOfftime(offtime int64) (hour, minite, second int) {
offtime = offtime % (3600 * 24)
hour = int(offtime / 3600)
offtime = offtime % 3600
minite = int(offtime / 60)
second = int(offtime % 60)
return
}

View File

@ -336,3 +336,26 @@ func TestSafeCastFloat64(t *testing.T) {
})
}
}
func TestParseOfftime(t *testing.T) {
cases := []struct {
offtime int64
hour int
minite int
second int
}{
{0, 0, 0, 0},
{1, 0, 0, 1},
{60, 0, 1, 0},
{3600, 1, 0, 0},
{3661, 1, 1, 1},
{3600*24 + 60, 0, 1, 0},
}
for _, c := range cases {
h, m, s := ParseOfftime(c.offtime)
assert.Equal(t, c.hour, h)
assert.Equal(t, c.minite, m)
assert.Equal(t, c.second, s)
}
}

View File

@ -15,7 +15,15 @@
package gc
import (
"fmt"
"net/http"
"os"
"github.com/vmware/harbor/src/common"
common_http "github.com/vmware/harbor/src/common/http"
"github.com/vmware/harbor/src/common/http/modifier/auth"
"github.com/vmware/harbor/src/common/registryctl"
reg "github.com/vmware/harbor/src/common/utils/registry"
"github.com/vmware/harbor/src/jobservice/env"
"github.com/vmware/harbor/src/jobservice/logger"
"github.com/vmware/harbor/src/registryctl/client"
@ -25,6 +33,9 @@ import (
type GarbageCollector struct {
registryCtlClient client.Client
logger logger.Interface
uiclient *common_http.Client
UIURL string
insecure bool
}
// MaxFails implements the interface in job/Interface
@ -47,6 +58,10 @@ func (gc *GarbageCollector) Run(ctx env.JobContext, params map[string]interface{
if err := gc.init(ctx); err != nil {
return err
}
if err := gc.readonly(true); err != nil {
return err
}
defer gc.readonly(false)
if err := gc.registryCtlClient.Health(); err != nil {
gc.logger.Errorf("failed to start gc as regsitry controller is unreachable: %v", err)
return err
@ -66,5 +81,29 @@ func (gc *GarbageCollector) init(ctx env.JobContext) error {
registryctl.Init()
gc.registryCtlClient = registryctl.RegistryCtlClient
gc.logger = ctx.GetLogger()
cred := auth.NewSecretAuthorizer(os.Getenv("JOBSERVICE_SECRET"))
gc.insecure = false
gc.uiclient = common_http.NewClient(&http.Client{
Transport: reg.GetHTTPTransport(gc.insecure),
}, cred)
errTpl := "Failed to get required property: %s"
if v, ok := ctx.Get(common.UIURL); ok && len(v.(string)) > 0 {
gc.UIURL = v.(string)
} else {
return fmt.Errorf(errTpl, common.UIURL)
}
return nil
}
func (gc *GarbageCollector) readonly(switcher bool) error {
if err := gc.uiclient.Put(fmt.Sprintf("%s/api/configurations", gc.UIURL), struct {
ReadOnly bool `json:"read_only"`
}{
ReadOnly: switcher,
}); err != nil {
gc.logger.Errorf("failed to send readonly request to %s: %v", gc.UIURL, err)
return err
}
gc.logger.Info("the readonly request has been sent successfully")
return nil
}

View File

@ -9,6 +9,7 @@ import (
"github.com/vmware/harbor/src/common/job"
job_models "github.com/vmware/harbor/src/common/job/models"
"github.com/vmware/harbor/src/common/models"
common_utils "github.com/vmware/harbor/src/common/utils"
"github.com/vmware/harbor/src/common/utils/log"
"github.com/vmware/harbor/src/replication"
"github.com/vmware/harbor/src/ui/config"
@ -39,10 +40,10 @@ func (st *ScheduleTrigger) Setup() error {
}
switch st.params.Type {
case replication.TriggerScheduleDaily:
h, m, s := parseOfftime(st.params.Offtime)
h, m, s := common_utils.ParseOfftime(st.params.Offtime)
metadata.Cron = fmt.Sprintf("%d %d %d * * *", s, m, h)
case replication.TriggerScheduleWeekly:
h, m, s := parseOfftime(st.params.Offtime)
h, m, s := common_utils.ParseOfftime(st.params.Offtime)
metadata.Cron = fmt.Sprintf("%d %d %d * * %d", s, m, h, st.params.Weekday%7)
default:
return fmt.Errorf("unsupported schedual trigger type: %s", st.params.Type)
@ -104,12 +105,3 @@ func (st *ScheduleTrigger) Unset() error {
}
return nil
}
func parseOfftime(offtime int64) (hour, minite, second int) {
offtime = offtime % (3600 * 24)
hour = int(offtime / 3600)
offtime = offtime % 3600
minite = int(offtime / 60)
second = int(offtime % 60)
return
}

View File

@ -25,26 +25,3 @@ func TestKindOfScheduleTrigger(t *testing.T) {
trigger := NewScheduleTrigger(ScheduleParam{})
assert.Equal(t, replication.TriggerKindSchedule, trigger.Kind())
}
func TestParseOfftime(t *testing.T) {
cases := []struct {
offtime int64
hour int
minite int
second int
}{
{0, 0, 0, 0},
{1, 0, 0, 1},
{60, 0, 1, 0},
{3600, 1, 0, 0},
{3661, 1, 1, 1},
{3600*24 + 60, 0, 1, 0},
}
for _, c := range cases {
h, m, s := parseOfftime(c.offtime)
assert.Equal(t, c.hour, h)
assert.Equal(t, c.minite, m)
assert.Equal(t, c.second, s)
}
}

View File

@ -38,7 +38,7 @@ func (c *ConfigAPI) Prepare() {
c.HandleUnauthorized()
return
}
if !c.SecurityCtx.IsSysAdmin() {
if !c.SecurityCtx.IsSysAdmin() && !c.SecurityCtx.IsSolutionUser() {
c.HandleForbidden(c.SecurityCtx.GetUsername())
return
}

View File

@ -28,6 +28,7 @@ import (
"strconv"
"github.com/vmware/harbor/src/common/dao"
"github.com/vmware/harbor/src/common/job/test"
"github.com/vmware/harbor/src/common/models"
"github.com/vmware/harbor/src/common/utils"
ldapUtils "github.com/vmware/harbor/src/common/utils/ldap"
@ -166,6 +167,9 @@ func init() {
beego.Router("/api/labels/:id([0-9]+", &LabelAPI{}, "get:Get;put:Put;delete:Delete")
beego.Router("/api/labels/:id([0-9]+)/resources", &LabelAPI{}, "get:ListResources")
beego.Router("/api/ping", &SystemInfoAPI{}, "get:Ping")
beego.Router("/api/system/gc/:id", &GCAPI{}, "get:GetGC")
beego.Router("/api/system/gc/:id([0-9]+)/log", &GCAPI{}, "get:GetLog")
beego.Router("/api/system/gc/schedule", &GCAPI{}, "get:Get;put:Put;post:Post")
_ = updateInitPassword(1, "Harbor12345")
if err := core.Init(); err != nil {
@ -182,6 +186,9 @@ func init() {
unknownUsr = &usrInfo{"unknown", "unknown"}
testUser = &usrInfo{TestUserName, TestUserPwd}
//Init mock jobservice
mockServer := test.NewJobServiceServer()
defer mockServer.Close()
}
func request(_sling *sling.Sling, acceptHeader string, authInfo ...usrInfo) (int, []byte, error) {
@ -1129,3 +1136,33 @@ func (a testapi) DeleteMeta(authInfor usrInfo, projectID int64, name string) (in
code, body, err := request(_sling, jsonAcceptHeader, authInfor)
return code, string(body), err
}
func (a testapi) AddGC(authInfor usrInfo, adminReq apilib.GCReq) (int, error) {
_sling := sling.New().Post(a.basePath)
path := "/api/system/gc/schedule"
_sling = _sling.Path(path)
// body params
_sling = _sling.BodyJSON(adminReq)
var httpStatusCode int
var err error
httpStatusCode, _, err = request(_sling, jsonAcceptHeader, authInfor)
return httpStatusCode, err
}
func (a testapi) GCScheduleGet(authInfo usrInfo) (int, []apilib.AdminJob, error) {
_sling := sling.New().Get(a.basePath)
path := "/api/system/gc/schedule"
_sling = _sling.Path(path)
httpStatusCode, body, err := request(_sling, jsonAcceptHeader, authInfo)
var successPayLoad []apilib.AdminJob
if 200 == httpStatusCode && nil == err {
err = json.Unmarshal(body, &successPayLoad)
}
return httpStatusCode, successPayLoad, err
}

124
src/ui/api/models/reg_gc.go Normal file
View File

@ -0,0 +1,124 @@
// Copyright (c) 2017 VMware, Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package models
import (
"encoding/json"
"fmt"
"github.com/astaxie/beego/validation"
"github.com/vmware/harbor/src/common/job"
"github.com/vmware/harbor/src/common/job/models"
"github.com/vmware/harbor/src/common/utils"
"github.com/vmware/harbor/src/common/utils/log"
"github.com/vmware/harbor/src/ui/config"
)
const (
//ScheduleDaily : 'Daily'
ScheduleDaily = "Daily"
//ScheduleWeekly : 'Weekly'
ScheduleWeekly = "Weekly"
//ScheduleManual : 'Manual'
ScheduleManual = "Manual"
//ScheduleNone : 'None'
ScheduleNone = "None"
)
// GCReq holds request information for admin job
type GCReq struct {
Schedule *ScheduleParam `json:"schedule"`
Status string `json:"status"`
ID int64 `json:"id"`
}
//ScheduleParam defines the parameter of schedule trigger
type ScheduleParam struct {
//Daily, Weekly, Manual, None
Type string `json:"type"`
//Optional, only used when type is 'weekly'
Weekday int8 `json:"Weekday"`
//The time offset with the UTC 00:00 in seconds
Offtime int64 `json:"Offtime"`
}
// Valid validates the gc request
func (gr *GCReq) Valid(v *validation.Validation) {
switch gr.Schedule.Type {
case ScheduleDaily, ScheduleWeekly:
if gr.Schedule.Offtime < 0 || gr.Schedule.Offtime > 3600*24 {
v.SetError("offtime", fmt.Sprintf("Invalid schedule trigger parameter offtime: %d", gr.Schedule.Offtime))
}
case ScheduleManual, ScheduleNone:
default:
v.SetError("kind", fmt.Sprintf("Invalid schedule kind: %s", gr.Schedule.Type))
}
}
// ToJob converts request to a job reconiged by job service.
func (gr *GCReq) ToJob() (*models.JobData, error) {
metadata := &models.JobMetadata{
JobKind: gr.JobKind(),
// GC job must be unique ...
IsUnique: true,
}
switch gr.Schedule.Type {
case ScheduleDaily:
h, m, s := utils.ParseOfftime(gr.Schedule.Offtime)
metadata.Cron = fmt.Sprintf("%d %d %d * * *", s, m, h)
case ScheduleWeekly:
h, m, s := utils.ParseOfftime(gr.Schedule.Offtime)
metadata.Cron = fmt.Sprintf("%d %d %d * * %d", s, m, h, gr.Schedule.Weekday%7)
case ScheduleManual, ScheduleNone:
default:
return nil, fmt.Errorf("unsupported schedual trigger type: %s", gr.Schedule.Type)
}
jobData := &models.JobData{
Name: job.ImageGC,
Metadata: metadata,
StatusHook: fmt.Sprintf("%s/service/notifications/jobs/adminjob/%d",
config.InternalUIURL(), gr.ID),
}
return jobData, nil
}
// IsPeriodic ...
func (gr *GCReq) IsPeriodic() bool {
return gr.JobKind() == job.JobKindPeriodic
}
// JobKind ...
func (gr *GCReq) JobKind() string {
switch gr.Schedule.Type {
case ScheduleDaily, ScheduleWeekly:
return job.JobKindPeriodic
case ScheduleManual:
return job.JobKindGeneric
default:
return ""
}
}
// CronString ...
func (gr *GCReq) CronString() string {
str, err := json.Marshal(gr.Schedule)
if err != nil {
log.Debugf("failed to marshal json error, %v", err)
return ""
}
return string(str)
}

View File

@ -0,0 +1,130 @@
// Copyright (c) 2017 VMware, Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package models
import (
"log"
"testing"
"github.com/stretchr/testify/assert"
"github.com/vmware/harbor/src/common"
common_job "github.com/vmware/harbor/src/common/job"
"github.com/vmware/harbor/src/common/utils/test"
)
var adminServerTestConfig = map[string]interface{}{
common.DefaultUIEndpoint: "test",
}
func TestMain(m *testing.M) {
server, err := test.NewAdminserver(adminServerTestConfig)
if err != nil {
log.Fatalf("failed to create a mock admin server: %v", err)
}
defer server.Close()
}
func TestToJob(t *testing.T) {
schedule := &ScheduleParam{
Type: "Daily",
Offtime: 200,
}
adminjob := &GCReq{
Schedule: schedule,
}
job, err := adminjob.ToJob()
assert.Nil(t, err)
assert.Equal(t, job.Name, "IMAGE_GC")
assert.Equal(t, job.Metadata.JobKind, common_job.JobKindPeriodic)
assert.Equal(t, job.Metadata.Cron, "20 3 0 * * *")
}
func TestToJobManual(t *testing.T) {
schedule := &ScheduleParam{
Type: "Manual",
}
adminjob := &GCReq{
Schedule: schedule,
}
job, err := adminjob.ToJob()
assert.Nil(t, err)
assert.Equal(t, job.Name, "IMAGE_GC")
assert.Equal(t, job.Metadata.JobKind, common_job.JobKindGeneric)
}
func TestToJobErr(t *testing.T) {
schedule := &ScheduleParam{
Type: "test",
}
adminjob := &GCReq{
Schedule: schedule,
}
_, err := adminjob.ToJob()
assert.NotNil(t, err)
}
func TestIsPeriodic(t *testing.T) {
schedule := &ScheduleParam{
Type: "Daily",
Offtime: 200,
}
adminjob := &GCReq{
Schedule: schedule,
}
isPeriodic := adminjob.IsPeriodic()
assert.Equal(t, isPeriodic, true)
}
func TestJobKind(t *testing.T) {
schedule := &ScheduleParam{
Type: "Daily",
Offtime: 200,
}
adminjob := &GCReq{
Schedule: schedule,
}
kind := adminjob.JobKind()
assert.Equal(t, kind, "Periodic")
schedule1 := &ScheduleParam{
Type: "Manual",
}
adminjob1 := &GCReq{
Schedule: schedule1,
}
kind1 := adminjob1.JobKind()
assert.Equal(t, kind1, "Generic")
}
func TestCronString(t *testing.T) {
schedule := &ScheduleParam{
Type: "Daily",
Offtime: 102,
}
adminjob := &GCReq{
Schedule: schedule,
}
cronStr := adminjob.CronString()
assert.Equal(t, cronStr, "{\"type\":\"Daily\",\"Weekday\":0,\"Offtime\":102}")
}

228
src/ui/api/reg_gc.go Normal file
View File

@ -0,0 +1,228 @@
// Copyright (c) 2017 VMware, Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package api
import (
"fmt"
"net/http"
"strconv"
"github.com/vmware/harbor/src/common/dao"
common_http "github.com/vmware/harbor/src/common/http"
common_job "github.com/vmware/harbor/src/common/job"
common_models "github.com/vmware/harbor/src/common/models"
"github.com/vmware/harbor/src/common/utils/log"
"github.com/vmware/harbor/src/ui/api/models"
utils_ui "github.com/vmware/harbor/src/ui/utils"
)
// GCAPI handles request of harbor admin...
type GCAPI struct {
BaseController
}
// Prepare validates the URL and parms, it needs the system admin permission.
func (gc *GCAPI) Prepare() {
gc.BaseController.Prepare()
if !gc.SecurityCtx.IsAuthenticated() {
gc.HandleUnauthorized()
return
}
if !gc.SecurityCtx.IsSysAdmin() {
gc.HandleForbidden(gc.SecurityCtx.GetUsername())
return
}
}
//Post ...
func (gc *GCAPI) Post() {
gr := models.GCReq{}
gc.DecodeJSONReqAndValidate(&gr)
gc.submitJob(&gr)
gc.Redirect(http.StatusCreated, strconv.FormatInt(gr.ID, 10))
}
//Put ...
func (gc *GCAPI) Put() {
gr := models.GCReq{}
gc.DecodeJSONReqAndValidate(&gr)
if gr.Schedule.Type == models.ScheduleManual {
gc.HandleInternalServerError(fmt.Sprintf("Fail to update GC schedule as wrong schedule type: %s.", gr.Schedule.Type))
return
}
query := &common_models.AdminJobQuery{
Name: common_job.ImageGC,
Kind: common_job.JobKindPeriodic,
}
jobs, err := dao.GetAdminJobs(query)
if err != nil {
gc.HandleInternalServerError(fmt.Sprintf("%v", err))
return
}
if len(jobs) != 1 {
gc.HandleInternalServerError("Fail to update GC schedule, only one schedule is accepted.")
return
}
// stop the scheduled job and remove it.
if err = utils_ui.GetJobServiceClient().PostAction(jobs[0].UUID, common_job.JobActionStop); err != nil {
if e, ok := err.(*common_http.Error); !ok || e.Code != http.StatusNotFound {
gc.HandleInternalServerError(fmt.Sprintf("%v", err))
return
}
}
if err = dao.DeleteAdminJob(jobs[0].ID); err != nil {
gc.HandleInternalServerError(fmt.Sprintf("%v", err))
return
}
// Set schedule to None means to cancel the schedule, won't add new job.
if gr.Schedule.Type != models.ScheduleNone {
gc.submitJob(&gr)
}
}
// GetGC ...
func (gc *GCAPI) GetGC() {
id, err := gc.GetInt64FromPath(":id")
if err != nil {
gc.HandleInternalServerError(fmt.Sprintf("need to specify gc id"))
return
}
jobs, err := dao.GetAdminJobs(&common_models.AdminJobQuery{
ID: id,
})
if err != nil {
gc.HandleInternalServerError(fmt.Sprintf("failed to get admin jobs: %v", err))
return
}
gc.Data["json"] = jobs
gc.ServeJSON()
}
// List ...
func (gc *GCAPI) List() {
jobs, err := dao.GetTop10AdminJobs()
if err != nil {
gc.HandleInternalServerError(fmt.Sprintf("failed to get admin jobs: %v", err))
return
}
gc.Data["json"] = jobs
gc.ServeJSON()
}
// Get gets GC schedule ...
func (gc *GCAPI) Get() {
jobs, err := dao.GetAdminJobs(&common_models.AdminJobQuery{
Name: common_job.ImageGC,
Kind: common_job.JobKindPeriodic,
})
if err != nil {
gc.HandleNotFound(fmt.Sprintf("failed to get admin jobs: %v", err))
return
}
if len(jobs) > 1 {
gc.HandleInternalServerError("Get more than one GC scheduled job, make sure there has only one.")
return
}
gc.Data["json"] = jobs
gc.ServeJSON()
}
//GetLog ...
func (gc *GCAPI) GetLog() {
id, err := gc.GetInt64FromPath(":id")
if err != nil {
gc.HandleBadRequest("invalid ID")
return
}
job, err := dao.GetAdminJob(id)
if err != nil {
log.Errorf("Failed to load job data for job: %d, error: %v", id, err)
gc.CustomAbort(http.StatusInternalServerError, "Failed to get Job data")
}
if job == nil {
log.Errorf("Failed to get admin job: %d", id)
gc.CustomAbort(http.StatusNotFound, "Failed to get Job")
}
logBytes, err := utils_ui.GetJobServiceClient().GetJobLog(job.UUID)
if err != nil {
if httpErr, ok := err.(*common_http.Error); ok {
gc.RenderError(httpErr.Code, "")
log.Errorf(fmt.Sprintf("failed to get log of job %d: %d %s",
id, httpErr.Code, httpErr.Message))
return
}
gc.HandleInternalServerError(fmt.Sprintf("Failed to get job logs, uuid: %s, error: %v", job.UUID, err))
return
}
gc.Ctx.ResponseWriter.Header().Set(http.CanonicalHeaderKey("Content-Length"), strconv.Itoa(len(logBytes)))
gc.Ctx.ResponseWriter.Header().Set(http.CanonicalHeaderKey("Content-Type"), "text/plain")
_, err = gc.Ctx.ResponseWriter.Write(logBytes)
if err != nil {
gc.HandleInternalServerError(fmt.Sprintf("Failed to write job logs, uuid: %s, error: %v", job.UUID, err))
}
}
// submitJob submits a job to job service per request
func (gc *GCAPI) submitJob(gr *models.GCReq) {
// cannot post multiple schdule for GC job.
if gr.IsPeriodic() {
jobs, err := dao.GetAdminJobs(&common_models.AdminJobQuery{
Name: common_job.ImageGC,
Kind: common_job.JobKindPeriodic,
})
if err != nil {
gc.HandleInternalServerError(fmt.Sprintf("failed to get admin jobs: %v", err))
return
}
if len(jobs) != 0 {
gc.HandleStatusPreconditionFailed("Fail to set schedule for GC as always had one, please delete it firstly then to re-schedule.")
return
}
}
id, err := dao.AddAdminJob(&common_models.AdminJob{
Name: common_job.ImageGC,
Kind: gr.JobKind(),
Cron: gr.CronString(),
})
if err != nil {
gc.HandleInternalServerError(fmt.Sprintf("%v", err))
return
}
gr.ID = id
job, err := gr.ToJob()
if err != nil {
gc.HandleInternalServerError(fmt.Sprintf("%v", err))
return
}
// submit job to jobservice
log.Debugf("submiting GC admin job to jobservice")
_, err = utils_ui.GetJobServiceClient().SubmitJob(job)
if err != nil {
if err := dao.DeleteAdminJob(id); err != nil {
log.Debugf("Failed to delete admin job, err: %v", err)
}
gc.HandleInternalServerError(fmt.Sprintf("%v", err))
return
}
}

39
src/ui/api/reg_gc_test.go Normal file
View File

@ -0,0 +1,39 @@
package api
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/vmware/harbor/tests/apitests/apilib"
)
var adminJob001 apilib.GCReq
var adminJob001schdeule apilib.ScheduleParam
func TestAdminJobPost(t *testing.T) {
assert := assert.New(t)
apiTest := newHarborAPI()
//case 1: add a new admin job
code, err := apiTest.AddGC(*admin, adminJob001)
if err != nil {
t.Error("Error occured while add a admin job", err.Error())
t.Log(err)
} else {
assert.Equal(200, code, "Add adminjob status should be 200")
}
}
func TestAdminJobGet(t *testing.T) {
assert := assert.New(t)
apiTest := newHarborAPI()
code, _, err := apiTest.GCScheduleGet(*admin)
if err != nil {
t.Error("Error occured while get a admin job", err.Error())
t.Log(err)
} else {
assert.Equal(200, code, "Get adminjob status should be 200")
}
}

View File

@ -18,6 +18,7 @@ import (
"github.com/vmware/harbor/src/ui/api"
"github.com/vmware/harbor/src/ui/config"
"github.com/vmware/harbor/src/ui/controllers"
"github.com/vmware/harbor/src/ui/service/notifications/admin"
"github.com/vmware/harbor/src/ui/service/notifications/clair"
"github.com/vmware/harbor/src/ui/service/notifications/jobs"
"github.com/vmware/harbor/src/ui/service/notifications/registry"
@ -89,6 +90,12 @@ func initRouters() {
beego.Router("/api/jobs/replication/:id([0-9]+)", &api.RepJobAPI{})
beego.Router("/api/jobs/replication/:id([0-9]+)/log", &api.RepJobAPI{}, "get:GetLog")
beego.Router("/api/jobs/scan/:id([0-9]+)/log", &api.ScanJobAPI{}, "get:GetLog")
beego.Router("/api/system/gc", &api.GCAPI{}, "get:List")
beego.Router("/api/system/gc/:id", &api.GCAPI{}, "get:GetGC")
beego.Router("/api/system/gc/:id([0-9]+)/log", &api.GCAPI{}, "get:GetLog")
beego.Router("/api/system/gc/schedule", &api.GCAPI{}, "get:Get;put:Put;post:Post")
beego.Router("/api/policies/replication/:id([0-9]+)", &api.RepPolicyAPI{})
beego.Router("/api/policies/replication", &api.RepPolicyAPI{}, "get:List")
beego.Router("/api/policies/replication", &api.RepPolicyAPI{}, "post:Post")
@ -118,6 +125,7 @@ func initRouters() {
beego.Router("/service/notifications/clair", &clair.Handler{}, "post:Handle")
beego.Router("/service/notifications/jobs/scan/:id([0-9]+)", &jobs.Handler{}, "post:HandleScan")
beego.Router("/service/notifications/jobs/replication/:id([0-9]+)", &jobs.Handler{}, "post:HandleReplication")
beego.Router("/service/notifications/jobs/adminjob/:id([0-9]+)", &admin.Handler{}, "post:HandleAdminJob")
beego.Router("/service/token", &token.Handler{})
beego.Router("/registryproxy/*", &controllers.RegistryProxy{}, "*:Handle")

View File

@ -0,0 +1,86 @@
// Copyright (c) 2017 VMware, Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package admin
import (
"encoding/json"
"github.com/vmware/harbor/src/common/dao"
"github.com/vmware/harbor/src/common/job"
job_model "github.com/vmware/harbor/src/common/job/models"
"github.com/vmware/harbor/src/common/models"
"github.com/vmware/harbor/src/common/utils/log"
"github.com/vmware/harbor/src/ui/api"
)
var statusMap = map[string]string{
job.JobServiceStatusPending: models.JobPending,
job.JobServiceStatusRunning: models.JobRunning,
job.JobServiceStatusStopped: models.JobStopped,
job.JobServiceStatusCancelled: models.JobCanceled,
job.JobServiceStatusError: models.JobError,
job.JobServiceStatusSuccess: models.JobFinished,
job.JobServiceStatusScheduled: models.JobScheduled,
}
// Handler handles reqeust on /service/notifications/jobs/adminjob/*, which listens to the webhook of jobservice.
type Handler struct {
api.BaseController
id int64
UUID string
status string
}
// Prepare ...
func (h *Handler) Prepare() {
var data job_model.JobStatusChange
err := json.Unmarshal(h.Ctx.Input.CopyBody(1<<32), &data)
if err != nil {
log.Errorf("Failed to decode job status change, error: %v", err)
h.Abort("200")
return
}
id, err := h.GetInt64FromPath(":id")
if err != nil {
log.Errorf("Failed to get job ID, error: %v", err)
//Avoid job service from resending...
h.Abort("200")
return
}
h.id = id
h.UUID = data.JobID
status, ok := statusMap[data.Status]
if !ok {
log.Infof("drop the job status update event: job id-%d, status-%s", h.id, status)
h.Abort("200")
return
}
h.status = status
}
//HandleAdminJob handles the webhook of admin jobs
func (h *Handler) HandleAdminJob() {
log.Infof("received admin job status update event: job-%d, status-%s", h.id, h.status)
// create the mapping relationship between the jobs in database and jobservice
if err := dao.SetAdminJobUUID(h.id, h.UUID); err != nil {
h.HandleInternalServerError(err.Error())
return
}
if err := dao.UpdateAdminJobStatus(h.id, h.status); err != nil {
log.Errorf("Failed to update job status, id: %d, status: %s", h.id, h.status)
h.HandleInternalServerError(err.Error())
return
}
}

View File

@ -0,0 +1,35 @@
/*
* Harbor API
*
* These APIs provide services for manipulating Harbor project.
*
* OpenAPI spec version: 0.3.0
*
* Generated by: https://github.com/swagger-api/swagger-codegen.git
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package apilib
// AdminJob ...
type AdminJob struct {
ID int64 `json:"id,omitempty"`
Name string `json:"job_name,omitempty"`
Kind string `json:"job_kind,omitempty"`
Status string `json:"job_status,omitempty"`
UUID string `json:"uuid,omitempty"`
Deleted bool `json:"deleted,omitempty"`
CreationTime string `json:"creation_time,omitempty"`
UpdateTime string `json:"update_time,omitempty"`
}

View File

@ -0,0 +1,37 @@
/*
* Harbor API
*
* These APIs provide services for manipulating Harbor project.
*
* OpenAPI spec version: 0.3.0
*
* Generated by: https://github.com/swagger-api/swagger-codegen.git
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package apilib
// GCReq holds request information for admin job
type GCReq struct {
Schedule *ScheduleParam `json:"schedule,omitempty"`
Status string `json:"status,omitempty"`
ID int64 `json:"id,omitempty"`
}
// ScheduleParam ...
type ScheduleParam struct {
Type string `json:"type,omitempty"`
Weekday int8 `json:"Weekday,omitempty"`
Offtime int64 `json:"Offtime,omitempty"`
}