diff --git a/src/jobservice/api/scan.go b/src/jobservice/api/scan.go index 207a3db9c..214590881 100644 --- a/src/jobservice/api/scan.go +++ b/src/jobservice/api/scan.go @@ -16,6 +16,7 @@ package api import ( "net/http" + "strconv" "github.com/vmware/harbor/src/common/dao" "github.com/vmware/harbor/src/common/models" @@ -83,3 +84,17 @@ func (isj *ImageScanJob) Post() { log.Debugf("Sent job to scheduler, job: %v", sj) job.Schedule(sj) } + +// GetLog gets logs of the job +func (isj *ImageScanJob) GetLog() { + idStr := isj.Ctx.Input.Param(":id") + jid, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + log.Errorf("Error parsing job id: %s, error: %v", idStr, err) + isj.RenderError(http.StatusBadRequest, "Invalid job id") + return + } + scanJob := job.NewScanJob(jid) + logFile := scanJob.LogPath() + isj.Ctx.Output.Download(logFile) +} diff --git a/src/jobservice/router.go b/src/jobservice/router.go index ba88442c4..94ba3b428 100644 --- a/src/jobservice/router.go +++ b/src/jobservice/router.go @@ -25,4 +25,5 @@ func initRouters() { beego.Router("/api/jobs/replication/:id/log", &api.ReplicationJob{}, "get:GetLog") beego.Router("/api/jobs/replication/actions", &api.ReplicationJob{}, "post:HandleAction") beego.Router("/api/jobs/scan", &api.ImageScanJob{}) + beego.Router("/api/jobs/scan/:id/log", &api.ImageScanJob{}, "get:GetLog") } diff --git a/src/ui/api/base.go b/src/ui/api/base.go index c940c8714..f33967926 100644 --- a/src/ui/api/base.go +++ b/src/ui/api/base.go @@ -34,6 +34,13 @@ type BaseController struct { ProjectMgr projectmanager.ProjectManager } +const ( + //ReplicationJobType ... + ReplicationJobType = "replication" + //ScanJobType ... + ScanJobType = "scan" +) + // Prepare inits security context and project manager from request // context func (b *BaseController) Prepare() { diff --git a/src/ui/api/replication_job.go b/src/ui/api/replication_job.go index f2f501eb0..e032eb0d2 100644 --- a/src/ui/api/replication_job.go +++ b/src/ui/api/replication_job.go @@ -16,8 +16,6 @@ package api import ( "fmt" - "io" - "io/ioutil" "net/http" "strconv" "time" @@ -147,38 +145,12 @@ func (ra *RepJobAPI) GetLog() { if ra.jobID == 0 { ra.CustomAbort(http.StatusBadRequest, "id is nil") } - - req, err := http.NewRequest("GET", buildJobLogURL(strconv.FormatInt(ra.jobID, 10)), nil) + url := buildJobLogURL(strconv.FormatInt(ra.jobID, 10), ReplicationJobType) + err := utils.RequestAsUI(http.MethodGet, url, nil, utils.NewJobLogRespHandler(&ra.BaseAPI)) if err != nil { - log.Errorf("failed to create a request: %v", err) - ra.CustomAbort(http.StatusInternalServerError, "") - } - utils.AddUISecret(req) - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - log.Errorf("failed to get log for job %d: %v", ra.jobID, err) - ra.CustomAbort(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusOK { - ra.Ctx.ResponseWriter.Header().Set(http.CanonicalHeaderKey("Content-Length"), resp.Header.Get(http.CanonicalHeaderKey("Content-Length"))) - ra.Ctx.ResponseWriter.Header().Set(http.CanonicalHeaderKey("Content-Type"), "text/plain") - - if _, err = io.Copy(ra.Ctx.ResponseWriter, resp.Body); err != nil { - log.Errorf("failed to write log to response; %v", err) - ra.CustomAbort(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) - } + ra.RenderError(http.StatusInternalServerError, err.Error()) return } - - b, err := ioutil.ReadAll(resp.Body) - if err != nil { - log.Errorf("failed to read reponse body: %v", err) - ra.CustomAbort(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) - } - - ra.CustomAbort(resp.StatusCode, string(b)) } //TODO:add Post handler to call job service API to submit jobs by policy diff --git a/src/ui/api/scan_job.go b/src/ui/api/scan_job.go new file mode 100644 index 000000000..6c6169138 --- /dev/null +++ b/src/ui/api/scan_job.go @@ -0,0 +1,68 @@ +// 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 ( + "github.com/vmware/harbor/src/common/dao" + "github.com/vmware/harbor/src/common/utils/log" + "github.com/vmware/harbor/src/ui/utils" + + "net/http" + "strconv" + "strings" +) + +// ScanJobAPI handles request to /api/scanJobs/:id/log +type ScanJobAPI struct { + BaseController + jobID int64 + projectName string +} + +// Prepare validates that whether user has read permission to the project of the repo the scan job scanned. +func (this *ScanJobAPI) Prepare() { + this.BaseController.Prepare() + if !this.SecurityCtx.IsAuthenticated() { + this.HandleUnauthorized() + return + } + id, err := this.GetInt64FromPath(":id") + if err != nil { + this.CustomAbort(http.StatusBadRequest, "ID is invalid") + } + this.jobID = id + + data, err := dao.GetScanJob(id) + if err != nil { + log.Errorf("Failed to load job data for job: %d, error: %v", id, err) + this.CustomAbort(http.StatusInternalServerError, "Failed to get Job data") + } + projectName := strings.SplitN(data.Repository, "/", 2)[0] + if !this.SecurityCtx.HasReadPerm(projectName) { + log.Errorf("User does not have read permission for project: %s", projectName) + this.HandleForbidden(this.SecurityCtx.GetUsername()) + } + this.projectName = projectName +} + +//GetLog ... +func (this *ScanJobAPI) GetLog() { + url := buildJobLogURL(strconv.FormatInt(this.jobID, 10), ScanJobType) + err := utils.RequestAsUI(http.MethodGet, url, nil, utils.NewJobLogRespHandler(&this.BaseAPI)) + if err != nil { + this.RenderError(http.StatusInternalServerError, err.Error()) + return + } +} diff --git a/src/ui/api/utils.go b/src/ui/api/utils.go index 9ec629218..a639057f9 100644 --- a/src/ui/api/utils.go +++ b/src/ui/api/utils.go @@ -97,7 +97,7 @@ func TriggerReplication(policyID int64, repository string, } url := buildReplicationURL() - return uiutils.RequestAsUI("POST", url, bytes.NewBuffer(b), http.StatusOK) + return uiutils.RequestAsUI("POST", url, bytes.NewBuffer(b), uiutils.NewStatusRespHandler(http.StatusOK)) } // TriggerReplicationByRepository triggers the replication according to the repository @@ -406,9 +406,9 @@ func buildReplicationURL() string { return fmt.Sprintf("%s/api/jobs/replication", url) } -func buildJobLogURL(jobID string) string { +func buildJobLogURL(jobID string, jobType string) string { url := config.InternalJobServiceURL() - return fmt.Sprintf("%s/api/jobs/replication/%s/log", url, jobID) + return fmt.Sprintf("%s/api/jobs/%s/%s/log", url, jobType, jobID) } func buildReplicationActionURL() string { diff --git a/src/ui/router.go b/src/ui/router.go index 46027a1cc..127c02565 100644 --- a/src/ui/router.go +++ b/src/ui/router.go @@ -99,6 +99,7 @@ func initRouters() { beego.Router("/api/jobs/replication/", &api.RepJobAPI{}, "get:List") 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/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") diff --git a/src/ui/utils/response_handlers.go b/src/ui/utils/response_handlers.go new file mode 100644 index 000000000..7a4f76c13 --- /dev/null +++ b/src/ui/utils/response_handlers.go @@ -0,0 +1,90 @@ +// 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 utils + +import ( + "fmt" + "io" + "io/ioutil" + "net/http" + + "github.com/vmware/harbor/src/common/api" + "github.com/vmware/harbor/src/common/utils/log" +) + +//ResponseHandler provides utility to handle http response. +type ResponseHandler interface { + Handle(*http.Response) error +} + +//StatusRespHandler handles the response to check if the status is expected, if not returns an error. +type StatusRespHandler struct { + status int +} + +//Handle ... +func (s StatusRespHandler) Handle(resp *http.Response) error { + defer resp.Body.Close() + if resp.StatusCode != s.status { + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + return fmt.Errorf("Unexpected status code: %d, text: %s", resp.StatusCode, string(b)) + } + return nil +} + +// NewStatusRespHandler ... +func NewStatusRespHandler(sc int) ResponseHandler { + return StatusRespHandler{ + status: sc, + } +} + +//JobLogRespHandler handles the response from jobservice to show the log of a job +type JobLogRespHandler struct { + theAPI *api.BaseAPI +} + +//Handle will consume the response of job service and put the content of the job log in the reponse of the API. +func (h JobLogRespHandler) Handle(resp *http.Response) error { + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + h.theAPI.Ctx.ResponseWriter.Header().Set(http.CanonicalHeaderKey("Content-Length"), resp.Header.Get(http.CanonicalHeaderKey("Content-Length"))) + h.theAPI.Ctx.ResponseWriter.Header().Set(http.CanonicalHeaderKey("Content-Type"), "text/plain") + + if _, err := io.Copy(h.theAPI.Ctx.ResponseWriter, resp.Body); err != nil { + log.Errorf("failed to write log to response; %v", err) + return err + } + return nil + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Errorf("failed to read reponse body: %v", err) + return err + } + h.theAPI.RenderError(resp.StatusCode, fmt.Sprintf("message from jobservice: %s", string(b))) + return nil +} + +//NewJobLogRespHandler ... +func NewJobLogRespHandler(apiHandler *api.BaseAPI) ResponseHandler { + return &JobLogRespHandler{ + theAPI: apiHandler, + } +} diff --git a/src/ui/utils/response_handlers_test.go b/src/ui/utils/response_handlers_test.go new file mode 100644 index 000000000..d6c4d5a54 --- /dev/null +++ b/src/ui/utils/response_handlers_test.go @@ -0,0 +1,40 @@ +// 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 utils + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStatusRespHandler(t *testing.T) { + assert := assert.New(t) + h := NewStatusRespHandler(http.StatusCreated) + recorder := httptest.NewRecorder() + recorder.WriteHeader(http.StatusCreated) + recorder.WriteString("test passed") + resp1 := recorder.Result() + err := h.Handle(resp1) + assert.Nil(err) + recorder2 := httptest.NewRecorder() + recorder2.WriteHeader(http.StatusForbidden) + recorder2.WriteString("test forbidden") + resp2 := recorder2.Result() + err = h.Handle(resp2) + assert.NotNil(err) + assert.Contains(err.Error(), "forbidden") +} diff --git a/src/ui/utils/utils.go b/src/ui/utils/utils.go index dab63ace7..025c452d5 100644 --- a/src/ui/utils/utils.go +++ b/src/ui/utils/utils.go @@ -27,7 +27,6 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "net/http" ) @@ -74,8 +73,7 @@ func ScanAllImages() error { // RequestAsUI is a shortcut to make a request attach UI secret and send the request. // Do not use this when you want to handle the response -// TODO: add a response handler to replace expectSC *when needed* -func RequestAsUI(method, url string, body io.Reader, expectSC int) error { +func RequestAsUI(method, url string, body io.Reader, h ResponseHandler) error { req, err := http.NewRequest(method, url, body) if err != nil { return err @@ -87,16 +85,7 @@ func RequestAsUI(method, url string, body io.Reader, expectSC int) error { if err != nil { return err } - defer resp.Body.Close() - - if resp.StatusCode != expectSC { - b, err := ioutil.ReadAll(resp.Body) - if err != nil { - return err - } - return fmt.Errorf("Unexpected status code: %d, text: %s", resp.StatusCode, string(b)) - } - return nil + return h.Handle(resp) } //AddUISecret add secret cookie to a request @@ -120,7 +109,7 @@ func TriggerImageScan(repository string, tag string) error { return err } url := fmt.Sprintf("%s/api/jobs/scan", config.InternalJobServiceURL()) - return RequestAsUI("POST", url, bytes.NewBuffer(b), http.StatusOK) + return RequestAsUI("POST", url, bytes.NewBuffer(b), NewStatusRespHandler(http.StatusOK)) } // NewRepositoryClientForUI ...