*: rework run approval and annotations

* runservice: use generic task annotations instead of approval annotations
* runservice: add method to set task annotations

* gateway: when an user call the run task approval action, it will set in the
task annotations the approval users ids. The task won't be approved.

* scheduler: when the number of approvers meets the required minimum number
(currently 1) call the runservice to approve the task

In this way we could easily implement some approval features like requiring a
minimum number of approvers (saved in the task annotations) before marking the
run as approved in the runservice.
This commit is contained in:
Simone Gotti 2019-05-06 15:19:29 +02:00
parent a590c21127
commit afae185e11
9 changed files with 221 additions and 43 deletions

View File

@ -16,8 +16,10 @@ package action
import ( import (
"context" "context"
"encoding/json"
"net/http" "net/http"
"github.com/sorintlab/agola/internal/services/gateway/common"
rsapi "github.com/sorintlab/agola/internal/services/runservice/scheduler/api" rsapi "github.com/sorintlab/agola/internal/services/runservice/scheduler/api"
"github.com/sorintlab/agola/internal/util" "github.com/sorintlab/agola/internal/util"
@ -25,11 +27,10 @@ import (
) )
func (h *ActionHandler) GetRun(ctx context.Context, runID string) (*rsapi.RunResponse, error) { func (h *ActionHandler) GetRun(ctx context.Context, runID string) (*rsapi.RunResponse, error) {
runResp, resp, err := h.runserviceClient.GetRun(ctx, runID) runResp, resp, err := h.runserviceClient.GetRun(ctx, runID, nil)
if err != nil { if err != nil {
return nil, ErrFromRemote(resp, err) return nil, ErrFromRemote(resp, err)
} }
canGetRun, err := h.CanGetRun(ctx, runResp.RunConfig.Group) canGetRun, err := h.CanGetRun(ctx, runResp.RunConfig.Group)
if err != nil { if err != nil {
return nil, errors.Wrapf(err, "failed to determine permissions") return nil, errors.Wrapf(err, "failed to determine permissions")
@ -79,7 +80,7 @@ type GetLogsRequest struct {
} }
func (h *ActionHandler) GetLogs(ctx context.Context, req *GetLogsRequest) (*http.Response, error) { func (h *ActionHandler) GetLogs(ctx context.Context, req *GetLogsRequest) (*http.Response, error) {
runResp, resp, err := h.runserviceClient.GetRun(ctx, req.RunID) runResp, resp, err := h.runserviceClient.GetRun(ctx, req.RunID, nil)
if err != nil { if err != nil {
return nil, ErrFromRemote(resp, err) return nil, ErrFromRemote(resp, err)
} }
@ -115,7 +116,7 @@ type RunActionsRequest struct {
} }
func (h *ActionHandler) RunAction(ctx context.Context, req *RunActionsRequest) error { func (h *ActionHandler) RunAction(ctx context.Context, req *RunActionsRequest) error {
runResp, resp, err := h.runserviceClient.GetRun(ctx, req.RunID) runResp, resp, err := h.runserviceClient.GetRun(ctx, req.RunID, nil)
if err != nil { if err != nil {
return ErrFromRemote(resp, err) return ErrFromRemote(resp, err)
} }
@ -166,28 +167,63 @@ type RunTaskActionsRequest struct {
RunID string RunID string
TaskID string TaskID string
ActionType RunTaskActionType ActionType RunTaskActionType
ApprovalAnnotations map[string]string
} }
func (h *ActionHandler) RunTaskAction(ctx context.Context, req *RunTaskActionsRequest) error { func (h *ActionHandler) RunTaskAction(ctx context.Context, req *RunTaskActionsRequest) error {
runResp, resp, err := h.runserviceClient.GetRun(ctx, req.RunID) runResp, resp, err := h.runserviceClient.GetRun(ctx, req.RunID, nil)
if err != nil { if err != nil {
return ErrFromRemote(resp, err) return ErrFromRemote(resp, err)
} }
canGetRun, err := h.CanDoRunActions(ctx, runResp.RunConfig.Group) canDoRunAction, err := h.CanDoRunActions(ctx, runResp.RunConfig.Group)
if err != nil { if err != nil {
return errors.Wrapf(err, "failed to determine permissions") return errors.Wrapf(err, "failed to determine permissions")
} }
if !canGetRun { if !canDoRunAction {
return util.NewErrForbidden(errors.Errorf("user not authorized")) return util.NewErrForbidden(errors.Errorf("user not authorized"))
} }
curUserID := h.CurrentUserID(ctx)
if curUserID == "" {
return util.NewErrBadRequest(errors.Errorf("no logged in user"))
}
switch req.ActionType { switch req.ActionType {
case RunTaskActionTypeApprove: case RunTaskActionTypeApprove:
rt, ok := runResp.Run.Tasks[req.TaskID]
if !ok {
return util.NewErrBadRequest(errors.Errorf("run %q doesn't have task %q", req.RunID, req.TaskID))
}
approvers := []string{}
annotations := map[string]string{}
if rt.Annotations != nil {
annotations = rt.Annotations
}
approversAnnotation, ok := annotations[common.ApproversAnnotation]
if ok {
if err := json.Unmarshal([]byte(approversAnnotation), &approvers); err != nil {
return errors.Wrapf(err, "failed to unmarshal run task approvers annotation")
}
}
for _, approver := range approvers {
if approver == curUserID {
return util.NewErrBadRequest(errors.Errorf("user %q alredy approved the task", approver))
}
}
approvers = append(approvers, curUserID)
approversj, err := json.Marshal(approvers)
if err != nil {
return errors.Wrapf(err, "failed to marshal run task approvers annotation")
}
annotations[common.ApproversAnnotation] = string(approversj)
rsreq := &rsapi.RunTaskActionsRequest{ rsreq := &rsapi.RunTaskActionsRequest{
ActionType: rsapi.RunTaskActionTypeApprove, ActionType: rsapi.RunTaskActionTypeSetAnnotations,
ApprovalAnnotations: req.ApprovalAnnotations, Annotations: annotations,
ChangeGroupsUpdateToken: runResp.ChangeGroupsUpdateToken,
} }
resp, err := h.runserviceClient.RunTaskActions(ctx, req.RunID, req.TaskID, rsreq) resp, err := h.runserviceClient.RunTaskActions(ctx, req.RunID, req.TaskID, rsreq)

View File

@ -153,7 +153,7 @@ func createRunResponseTask(r *rstypes.Run, rt *rstypes.RunTask, rct *rstypes.Run
WaitingApproval: rt.WaitingApproval, WaitingApproval: rt.WaitingApproval,
Approved: rt.Approved, Approved: rt.Approved,
ApprovalAnnotations: rt.ApprovalAnnotations, ApprovalAnnotations: rt.Annotations,
Level: rct.Level, Level: rct.Level,
Depends: rct.Depends, Depends: rct.Depends,
@ -170,7 +170,7 @@ func createRunTaskResponse(rt *rstypes.RunTask, rct *rstypes.RunConfigTask) *Run
WaitingApproval: rt.WaitingApproval, WaitingApproval: rt.WaitingApproval,
Approved: rt.Approved, Approved: rt.Approved,
ApprovalAnnotations: rt.ApprovalAnnotations, ApprovalAnnotations: rt.Annotations,
Steps: make([]*RunTaskResponseStep, len(rt.Steps)), Steps: make([]*RunTaskResponseStep, len(rt.Steps)),
@ -415,8 +415,7 @@ func (h *RunActionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
type RunTaskActionsRequest struct { type RunTaskActionsRequest struct {
ActionType action.RunTaskActionType `json:"action_type"` ActionType action.RunTaskActionType `json:"action_type"`
ApprovalAnnotations map[string]string `json:"approval_annotations,omitempty"`
} }
type RunTaskActionsHandler struct { type RunTaskActionsHandler struct {
@ -442,10 +441,9 @@ func (h *RunTaskActionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
} }
areq := &action.RunTaskActionsRequest{ areq := &action.RunTaskActionsRequest{
RunID: runID, RunID: runID,
TaskID: taskID, TaskID: taskID,
ActionType: req.ActionType, ActionType: req.ActionType,
ApprovalAnnotations: req.ApprovalAnnotations,
} }
err := h.ah.RunTaskAction(ctx, areq) err := h.ah.RunTaskAction(ctx, areq)

View File

@ -35,6 +35,8 @@ const (
GroupTypeBranch GroupType = "branch" GroupTypeBranch GroupType = "branch"
GroupTypeTag GroupType = "tag" GroupTypeTag GroupType = "tag"
GroupTypePullRequest GroupType = "pr" GroupTypePullRequest GroupType = "pr"
ApproversAnnotation = "approvers"
) )
func GenRunGroup(baseGroupType GroupType, baseGroupID string, webhookData *types.WebhookData) string { func GenRunGroup(baseGroupType GroupType, baseGroupID string, webhookData *types.WebhookData) string {

View File

@ -263,7 +263,7 @@ func (g *Gateway) Run(ctx context.Context) error {
apirouter.Handle("/runs/{runid}", authForcedHandler(runHandler)).Methods("GET") apirouter.Handle("/runs/{runid}", authForcedHandler(runHandler)).Methods("GET")
apirouter.Handle("/runs/{runid}/actions", authForcedHandler(runActionsHandler)).Methods("PUT") apirouter.Handle("/runs/{runid}/actions", authForcedHandler(runActionsHandler)).Methods("PUT")
apirouter.Handle("/runs/{runid}/tasks/{taskid}", authForcedHandler(runtaskHandler)).Methods("GET") apirouter.Handle("/runs/{runid}/tasks/{taskid}", authForcedHandler(runtaskHandler)).Methods("GET")
apirouter.Handle("/runs/{runid}/tasks/{taskid}/actions", runTaskActionsHandler).Methods("PUT") apirouter.Handle("/runs/{runid}/tasks/{taskid}/actions", authForcedHandler(runTaskActionsHandler)).Methods("PUT")
apirouter.Handle("/runs", authForcedHandler(runsHandler)).Methods("GET") apirouter.Handle("/runs", authForcedHandler(runsHandler)).Methods("GET")
// TODO(sgotti) add auth to these requests // TODO(sgotti) add auth to these requests

View File

@ -469,10 +469,38 @@ func genRun(rc *types.RunConfig) *types.Run {
return r return r
} }
type RunTaskSetAnnotationsRequest struct {
RunID string
TaskID string
Annotations map[string]string
ChangeGroupsUpdateToken string
}
func (h *ActionHandler) RunTaskSetAnnotations(ctx context.Context, req *RunTaskSetAnnotationsRequest) error {
cgt, err := types.UnmarshalChangeGroupsUpdateToken(req.ChangeGroupsUpdateToken)
if err != nil {
return err
}
r, _, err := store.GetRun(ctx, h.e, req.RunID)
if err != nil {
return err
}
task, ok := r.Tasks[req.TaskID]
if !ok {
return util.NewErrBadRequest(errors.Errorf("run %q doesn't have task %q", r.ID, req.TaskID))
}
task.Annotations = req.Annotations
_, err = store.AtomicPutRun(ctx, h.e, r, nil, cgt)
return err
}
type RunTaskApproveRequest struct { type RunTaskApproveRequest struct {
RunID string RunID string
TaskID string TaskID string
ApprovalAnnotations map[string]string
ChangeGroupsUpdateToken string ChangeGroupsUpdateToken string
} }
@ -502,7 +530,6 @@ func (h *ActionHandler) ApproveRunTask(ctx context.Context, req *RunTaskApproveR
task.WaitingApproval = false task.WaitingApproval = false
task.Approved = true task.Approved = true
task.ApprovalAnnotations = req.ApprovalAnnotations
_, err = store.AtomicPutRun(ctx, h.e, r, nil, cgt) _, err = store.AtomicPutRun(ctx, h.e, r, nil, cgt)
return err return err

View File

@ -635,13 +635,18 @@ func (h *RunActionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
type RunTaskActionType string type RunTaskActionType string
const ( const (
RunTaskActionTypeApprove RunTaskActionType = "approve" RunTaskActionTypeSetAnnotations RunTaskActionType = "setannotations"
RunTaskActionTypeApprove RunTaskActionType = "approve"
) )
type RunTaskActionsRequest struct { type RunTaskActionsRequest struct {
ActionType RunTaskActionType `json:"action_type"` ActionType RunTaskActionType `json:"action_type"`
ApprovalAnnotations map[string]string `json:"approval_annotations,omitempty"`
ChangeGroupsUpdateToken string `json:"change_groups_update_tokens"` // set Annotations fields
Annotations map[string]string `json:"annotations,omitempty"`
// global fields
ChangeGroupsUpdateToken string `json:"change_groups_update_tokens"`
} }
type RunTaskActionsHandler struct { type RunTaskActionsHandler struct {
@ -671,6 +676,19 @@ func (h *RunTaskActionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
} }
switch req.ActionType { switch req.ActionType {
case RunTaskActionTypeSetAnnotations:
creq := &action.RunTaskSetAnnotationsRequest{
RunID: runID,
TaskID: taskID,
Annotations: req.Annotations,
ChangeGroupsUpdateToken: req.ChangeGroupsUpdateToken,
}
if err := h.ah.RunTaskSetAnnotations(ctx, creq); err != nil {
h.log.Errorf("err: %+v", err)
httpError(w, err)
return
}
case RunTaskActionTypeApprove: case RunTaskActionTypeApprove:
creq := &action.RunTaskApproveRequest{ creq := &action.RunTaskApproveRequest{
RunID: runID, RunID: runID,
@ -682,6 +700,7 @@ func (h *RunTaskActionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
httpError(w, err) httpError(w, err)
return return
} }
default: default:
http.Error(w, "", http.StatusBadRequest) http.Error(w, "", http.StatusBadRequest)
return return

View File

@ -194,8 +194,12 @@ func (c *Client) GetRuns(ctx context.Context, phaseFilter, groups []string, last
return getRunsResponse, resp, err return getRunsResponse, resp, err
} }
func (c *Client) GetQueuedRuns(ctx context.Context, start string, limit int) (*GetRunsResponse, *http.Response, error) { func (c *Client) GetQueuedRuns(ctx context.Context, start string, limit int, changeGroups []string) (*GetRunsResponse, *http.Response, error) {
return c.GetRuns(ctx, []string{"queued"}, []string{}, false, nil, start, limit, true) return c.GetRuns(ctx, []string{"queued"}, []string{}, false, changeGroups, start, limit, true)
}
func (c *Client) GetRunningRuns(ctx context.Context, start string, limit int, changeGroups []string) (*GetRunsResponse, *http.Response, error) {
return c.GetRuns(ctx, []string{"running"}, []string{}, false, changeGroups, start, limit, true)
} }
func (c *Client) GetGroupQueuedRuns(ctx context.Context, group string, limit int, changeGroups []string) (*GetRunsResponse, *http.Response, error) { func (c *Client) GetGroupQueuedRuns(ctx context.Context, group string, limit int, changeGroups []string) (*GetRunsResponse, *http.Response, error) {
@ -245,19 +249,33 @@ func (c *Client) RunTaskActions(ctx context.Context, runID, taskID string, req *
return c.getResponse(ctx, "PUT", fmt.Sprintf("/runs/%s/tasks/%s/actions", runID, taskID), nil, -1, jsonContent, bytes.NewReader(reqj)) return c.getResponse(ctx, "PUT", fmt.Sprintf("/runs/%s/tasks/%s/actions", runID, taskID), nil, -1, jsonContent, bytes.NewReader(reqj))
} }
func (c *Client) ApproveRunTask(ctx context.Context, runID, taskID string, approvalAnnotations map[string]string, changeGroupsUpdateToken string) (*http.Response, error) { func (c *Client) RunTaskSetAnnotations(ctx context.Context, runID, taskID string, annotations map[string]string, changeGroupsUpdateToken string) (*http.Response, error) {
req := &RunTaskActionsRequest{ req := &RunTaskActionsRequest{
ActionType: RunTaskActionTypeApprove, ActionType: RunTaskActionTypeSetAnnotations,
ApprovalAnnotations: approvalAnnotations, Annotations: annotations,
ChangeGroupsUpdateToken: changeGroupsUpdateToken, ChangeGroupsUpdateToken: changeGroupsUpdateToken,
} }
return c.RunTaskActions(ctx, runID, taskID, req) return c.RunTaskActions(ctx, runID, taskID, req)
} }
func (c *Client) GetRun(ctx context.Context, runID string) (*RunResponse, *http.Response, error) { func (c *Client) ApproveRunTask(ctx context.Context, runID, taskID string, changeGroupsUpdateToken string) (*http.Response, error) {
req := &RunTaskActionsRequest{
ActionType: RunTaskActionTypeApprove,
ChangeGroupsUpdateToken: changeGroupsUpdateToken,
}
return c.RunTaskActions(ctx, runID, taskID, req)
}
func (c *Client) GetRun(ctx context.Context, runID string, changeGroups []string) (*RunResponse, *http.Response, error) {
q := url.Values{}
for _, changeGroup := range changeGroups {
q.Add("changegroup", changeGroup)
}
runResponse := new(RunResponse) runResponse := new(RunResponse)
resp, err := c.getParsedResponse(ctx, "GET", fmt.Sprintf("/runs/%s", runID), nil, jsonContent, nil, runResponse) resp, err := c.getParsedResponse(ctx, "GET", fmt.Sprintf("/runs/%s", runID), q, jsonContent, nil, runResponse)
return runResponse, resp, err return runResponse, resp, err
} }

View File

@ -95,7 +95,7 @@ type Run struct {
// /project/$projectid/pr/$prid // /project/$projectid/pr/$prid
Group string `json:"group,omitempty"` Group string `json:"group,omitempty"`
// Annotations contain custom run properties // Annotations contain custom run annotations
Annotations map[string]string `json:"annotations,omitempty"` Annotations map[string]string `json:"annotations,omitempty"`
// Phase represent the current run status. A run could be running but already // Phase represent the current run status. A run could be running but already
@ -219,14 +219,15 @@ type RunTask struct {
// there're no executor tasks scheduled // there're no executor tasks scheduled
Status RunTaskStatus `json:"status,omitempty"` Status RunTaskStatus `json:"status,omitempty"`
// Annotations contain custom task annotations
// these are opaque to the runservice and used for multiple pourposes. For
// example to stores task approval metadata.
Annotations map[string]string `json:"annotations,omitempty"`
Skip bool `json:"skip,omitempty"` Skip bool `json:"skip,omitempty"`
WaitingApproval bool `json:"waiting_approval,omitempty"` WaitingApproval bool `json:"waiting_approval,omitempty"`
Approved bool `json:"approved,omitempty"` Approved bool `json:"approved,omitempty"`
// ApprovalAnnotations stores data that the user can set on the approval. Useful
// to save approval information like the user who approved the task.
// This data is opaque to the run service
ApprovalAnnotations map[string]string `json:"approval_annotations,omitempty"`
SetupStep RunTaskStep `json:"setup_step,omitempty"` SetupStep RunTaskStep `json:"setup_step,omitempty"`
Steps []*RunTaskStep `json:"steps,omitempty"` Steps []*RunTaskStep `json:"steps,omitempty"`
@ -291,7 +292,7 @@ type RunConfig struct {
// A list of setup errors when the run is in phase setuperror // A list of setup errors when the run is in phase setuperror
SetupErrors []string `json:"setup_errors,omitempty"` SetupErrors []string `json:"setup_errors,omitempty"`
// Annotations contain custom run properties // Annotations contain custom run annotations
// Note: Annotations are currently both saved in a Run and in RunConfig to // Note: Annotations are currently both saved in a Run and in RunConfig to
// easily return them without loading RunConfig from the lts // easily return them without loading RunConfig from the lts
Annotations map[string]string `json:"annotations,omitempty"` Annotations map[string]string `json:"annotations,omitempty"`

View File

@ -16,11 +16,13 @@ package scheduler
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"time" "time"
slog "github.com/sorintlab/agola/internal/log" slog "github.com/sorintlab/agola/internal/log"
"github.com/sorintlab/agola/internal/services/config" "github.com/sorintlab/agola/internal/services/config"
"github.com/sorintlab/agola/internal/services/gateway/common"
rsapi "github.com/sorintlab/agola/internal/services/runservice/scheduler/api" rsapi "github.com/sorintlab/agola/internal/services/runservice/scheduler/api"
"github.com/sorintlab/agola/internal/util" "github.com/sorintlab/agola/internal/util"
@ -48,7 +50,7 @@ func (s *Scheduler) schedule(ctx context.Context) error {
var lastRunID string var lastRunID string
for { for {
queuedRunsResponse, _, err := s.runserviceClient.GetQueuedRuns(ctx, lastRunID, 0) queuedRunsResponse, _, err := s.runserviceClient.GetQueuedRuns(ctx, lastRunID, 0, nil)
if err != nil { if err != nil {
return errors.Wrapf(err, "failed to get queued runs") return errors.Wrapf(err, "failed to get queued runs")
} }
@ -62,9 +64,7 @@ func (s *Scheduler) schedule(ctx context.Context) error {
break break
} }
if len(queuedRunsResponse.Runs) > 0 { lastRunID = queuedRunsResponse.Runs[len(queuedRunsResponse.Runs)-1].ID
lastRunID = queuedRunsResponse.Runs[len(queuedRunsResponse.Runs)-1].ID
}
} }
for groupID := range groups { for groupID := range groups {
@ -107,6 +107,82 @@ func (s *Scheduler) scheduleRun(ctx context.Context, groupID string) error {
return nil return nil
} }
func (s *Scheduler) approveLoop(ctx context.Context) {
for {
if err := s.approve(ctx); err != nil {
log.Errorf("err: %+v", err)
}
time.Sleep(1 * time.Second)
}
}
func (s *Scheduler) approve(ctx context.Context) error {
var lastRunID string
for {
runningRunsResponse, _, err := s.runserviceClient.GetRunningRuns(ctx, lastRunID, 0, nil)
if err != nil {
return errors.Wrapf(err, "failed to get running runs")
}
if len(runningRunsResponse.Runs) == 0 {
break
}
for _, run := range runningRunsResponse.Runs {
if err := s.approveRunTasks(ctx, run.ID); err != nil {
// just log error and continue with the other runs
log.Errorf("failed to approve run tasks for run %q: %+v", run.ID, err)
}
}
lastRunID = runningRunsResponse.Runs[len(runningRunsResponse.Runs)-1].ID
}
return nil
}
func (s *Scheduler) approveRunTasks(ctx context.Context, runID string) error {
// refetch run with a dedicated changegroup
changegroup := util.EncodeSha256Hex(fmt.Sprintf("approval-%s", runID))
runResp, _, err := s.runserviceClient.GetRun(ctx, runID, []string{changegroup})
if err != nil {
return errors.Wrapf(err, "failed to get run %q", runID)
}
run := runResp.Run
tasksWaitingApproval := run.TasksWaitingApproval()
for _, rtID := range tasksWaitingApproval {
rt, ok := run.Tasks[rtID]
if !ok {
return util.NewErrBadRequest(errors.Errorf("run %q doesn't have task %q", run.ID, rtID))
}
annotations := rt.Annotations
if annotations == nil {
continue
}
approversAnnotation, ok := annotations[common.ApproversAnnotation]
if !ok {
continue
}
var approvers []string
if err := json.Unmarshal([]byte(approversAnnotation), &approvers); err != nil {
return errors.Wrapf(err, "failed to unmarshal run task approvers annotation")
}
// TODO(sgotti) change when we introduce a config the set the minimum number of required approvers
if len(approvers) > 0 {
rsreq := &rsapi.RunTaskActionsRequest{
ActionType: rsapi.RunTaskActionTypeApprove,
ChangeGroupsUpdateToken: runResp.ChangeGroupsUpdateToken,
}
if _, err := s.runserviceClient.RunTaskActions(ctx, run.ID, rt.ID, rsreq); err != nil {
return errors.Wrapf(err, "failed to approve run")
}
}
}
return nil
}
type Scheduler struct { type Scheduler struct {
c *config.Scheduler c *config.Scheduler
runserviceClient *rsapi.Client runserviceClient *rsapi.Client
@ -124,6 +200,7 @@ func NewScheduler(c *config.Scheduler) (*Scheduler, error) {
func (s *Scheduler) Run(ctx context.Context) error { func (s *Scheduler) Run(ctx context.Context) error {
go s.scheduleLoop(ctx) go s.scheduleLoop(ctx)
go s.approveLoop(ctx)
select { select {
case <-ctx.Done(): case <-ctx.Done():