diff --git a/server/neptune/lyft/activities/audit.go b/server/neptune/lyft/activities/audit.go index b4cdd3b55..a4f412545 100644 --- a/server/neptune/lyft/activities/audit.go +++ b/server/neptune/lyft/activities/audit.go @@ -53,7 +53,6 @@ type AtlantisJobEvent struct { // ProjectName in the atlantis.yaml RootName string `json:"root_name"` - // Currently we do not track approvers metadata. ApprovedBy string `json:"approved_by"` ApprovedTime string `json:"approved_time"` } @@ -83,6 +82,8 @@ type AuditJobRequest struct { EndTime string IsForceApply bool Tags map[string]string + ApprovedBy string + ApprovedTime string } func NewAuditActivity(snsTopicArn string) (*Audit, error) { @@ -123,6 +124,8 @@ func (a *Audit) AuditJob(ctx context.Context, req AuditJobRequest) error { Revision: req.Revision, Project: req.Tags[ProjectTagKey], Environment: req.Tags[EnvironmentTagKey], + ApprovedBy: req.ApprovedBy, + ApprovedTime: req.ApprovedTime, } if req.State == AtlantisJobStateFailure || req.State == AtlantisJobStateSuccess { diff --git a/server/neptune/lyft/notifier/sns.go b/server/neptune/lyft/notifier/sns.go index 07f602483..50ae48de2 100644 --- a/server/neptune/lyft/notifier/sns.go +++ b/server/neptune/lyft/notifier/sns.go @@ -2,13 +2,12 @@ package notifier import ( "context" + "strconv" "github.com/runatlantis/atlantis/server/neptune/lyft/activities" t "github.com/runatlantis/atlantis/server/neptune/workflows/activities/terraform" "github.com/runatlantis/atlantis/server/neptune/workflows/plugins" "go.temporal.io/sdk/workflow" - - "strconv" ) type auditActivity interface { @@ -45,6 +44,11 @@ func (n *SNSNotifier) Notify(ctx workflow.Context, deploymentInfo plugins.Terraf return nil } + var approvedTime string + if !jobState.ApprovedTime.IsZero() { + approvedTime = strconv.FormatInt(jobState.ApprovedTime.Unix(), 10) + } + auditJobReq := activities.AuditJobRequest{ Repo: deploymentInfo.Repo, Root: deploymentInfo.Root, @@ -56,6 +60,8 @@ func (n *SNSNotifier) Notify(ctx workflow.Context, deploymentInfo plugins.Terraf StartTime: startTime, EndTime: endTime, IsForceApply: deploymentInfo.Root.TriggerInfo.Type == t.ManualTrigger && deploymentInfo.Root.TriggerInfo.Force, + ApprovedBy: jobState.ApprovedBy, + ApprovedTime: approvedTime, } return workflow.ExecuteActivity(ctx, n.Activity.AuditJob, auditJobReq).Get(ctx, nil) diff --git a/server/neptune/lyft/notifier/sns_test.go b/server/neptune/lyft/notifier/sns_test.go index 985ad93d0..4ae63b79b 100644 --- a/server/neptune/lyft/notifier/sns_test.go +++ b/server/neptune/lyft/notifier/sns_test.go @@ -59,6 +59,7 @@ func testSNSNotifierWorkflow(ctx workflow.Context, r snsNotifierRequest) error { func TestSNSNotifier_SendsMessage(t *testing.T) { stTime := time.Now() endTime := stTime.Add(time.Second * 5) + approvalTime := stTime.Add(-time.Minute) internalDeploymentInfo := plugins.TerraformDeploymentInfo{ ID: uuid.New(), Root: terraform.Root{Name: "root"}, @@ -78,8 +79,10 @@ func TestSNSNotifier_SendsMessage(t *testing.T) { Status: plugins.SuccessJobStatus, }, Apply: &plugins.JobState{ - Status: plugins.InProgressJobStatus, - StartTime: stTime, + Status: plugins.InProgressJobStatus, + StartTime: stTime, + ApprovedBy: "octocat", + ApprovedTime: approvalTime, }, }, ExpectedAuditJobRequest: activities.AuditJobRequest{ @@ -89,6 +92,8 @@ func TestSNSNotifier_SendsMessage(t *testing.T) { State: activities.AtlantisJobStateRunning, StartTime: strconv.FormatInt(stTime.Unix(), 10), IsForceApply: false, + ApprovedBy: "octocat", + ApprovedTime: strconv.FormatInt(approvalTime.Unix(), 10), }, }, { @@ -160,9 +165,11 @@ func TestSNSNotifier_SendsMessage(t *testing.T) { Status: plugins.SuccessJobStatus, }, Apply: &plugins.JobState{ - Status: plugins.SuccessJobStatus, - StartTime: stTime, - EndTime: endTime, + Status: plugins.SuccessJobStatus, + StartTime: stTime, + EndTime: endTime, + ApprovedBy: "octocat", + ApprovedTime: approvalTime, }, }, ExpectedAuditJobRequest: activities.AuditJobRequest{ @@ -173,6 +180,8 @@ func TestSNSNotifier_SendsMessage(t *testing.T) { StartTime: strconv.FormatInt(stTime.Unix(), 10), EndTime: strconv.FormatInt(endTime.Unix(), 10), IsForceApply: false, + ApprovedBy: "octocat", + ApprovedTime: strconv.FormatInt(approvalTime.Unix(), 10), }, }, } diff --git a/server/neptune/workflows/internal/terraform/gate/review.go b/server/neptune/workflows/internal/terraform/gate/review.go index cb336106f..42bc88847 100644 --- a/server/neptune/workflows/internal/terraform/gate/review.go +++ b/server/neptune/workflows/internal/terraform/gate/review.go @@ -19,9 +19,7 @@ const ( type PlanStatus int type PlanReviewSignalRequest struct { Status PlanStatus - - // TODO: Output this info to the checks UI - User string + User string } const ( @@ -29,6 +27,13 @@ const ( Rejected ) +// ReviewResult holds the outcome of a plan review gate. +type ReviewResult struct { + Status PlanStatus + ApprovedBy string + ApprovedTime time.Time +} + // Review waits for a plan review signal or a timeout to occur and returns an associated status. type Review struct { MetricsHandler client.MetricsHandler @@ -40,9 +45,11 @@ type ActionsClient interface { UpdateApprovalActions(approval terraform.PlanApproval) error } -func (r *Review) Await(ctx workflow.Context, root terraform.Root, planSummary terraform.PlanSummary) (PlanStatus, error) { +// Await blocks until the plan is approved, rejected, or times out. +// On approval it returns the approver username and the time of approval. +func (r *Review) Await(ctx workflow.Context, root terraform.Root, planSummary terraform.PlanSummary) (ReviewResult, error) { if root.Plan.Approval.Type == terraform.AutoApproval || planSummary.IsEmpty() { - return Approved, nil + return ReviewResult{Status: Approved}, nil } waitStartTime := time.Now() @@ -56,8 +63,10 @@ func (r *Review) Await(ctx workflow.Context, root terraform.Root, planSummary te } var planReview PlanReviewSignalRequest + var approvedAt time.Time selector.AddReceive(ch, func(c workflow.ReceiveChannel, more bool) { ch.Receive(ctx, &planReview) + approvedAt = workflow.Now(ctx) }) var timedOut bool @@ -70,14 +79,18 @@ func (r *Review) Await(ctx workflow.Context, root terraform.Root, planSummary te err := r.Client.UpdateApprovalActions(root.Plan.Approval) if err != nil { - return Rejected, errors.Wrap(err, "updating approval actions") + return ReviewResult{Status: Rejected}, errors.Wrap(err, "updating approval actions") } selector.Select(ctx) if timedOut { - return Rejected, nil + return ReviewResult{Status: Rejected}, nil } - return planReview.Status, nil + return ReviewResult{ + Status: planReview.Status, + ApprovedBy: planReview.User, + ApprovedTime: approvedAt, + }, nil } diff --git a/server/neptune/workflows/internal/terraform/gate/review_test.go b/server/neptune/workflows/internal/terraform/gate/review_test.go index ead519e74..9d4246b8c 100644 --- a/server/neptune/workflows/internal/terraform/gate/review_test.go +++ b/server/neptune/workflows/internal/terraform/gate/review_test.go @@ -13,7 +13,7 @@ import ( ) type res struct { - Status gate.PlanStatus + ReviewResult gate.ReviewResult ActionsClientCalled bool ActionsClientCapturedApproval terraform.PlanApproval } @@ -47,14 +47,14 @@ func testReviewWorkflow(ctx workflow.Context, r req) (res, error) { Client: c, } - status, err := review.Await(ctx, terraform.Root{ + result, err := review.Await(ctx, terraform.Root{ Plan: terraform.PlanJob{ Approval: r.ApprovalOverride, }, }, r.PlanSummary) return res{ - Status: status, + ReviewResult: result, ActionsClientCalled: c.called, ActionsClientCapturedApproval: c.capturedApproval, }, err @@ -80,11 +80,11 @@ func TestAwait_timesOut(t *testing.T) { err := env.GetWorkflowResult(&r) assert.NoError(t, err) - assert.Equal(t, r, res{ - Status: gate.Rejected, - ActionsClientCalled: true, - ActionsClientCapturedApproval: approvalOverride, - }) + assert.Equal(t, gate.Rejected, r.ReviewResult.Status) + assert.Empty(t, r.ReviewResult.ApprovedBy) + assert.True(t, r.ReviewResult.ApprovedTime.IsZero()) + assert.True(t, r.ActionsClientCalled) + assert.Equal(t, approvalOverride, r.ActionsClientCapturedApproval) } func TestAwait_approvesEmptyPlan(t *testing.T) { @@ -101,10 +101,10 @@ func TestAwait_approvesEmptyPlan(t *testing.T) { err := env.GetWorkflowResult(&r) assert.NoError(t, err) - assert.Equal(t, r, res{ - Status: gate.Approved, - }) + assert.Equal(t, gate.Approved, r.ReviewResult.Status) + assert.Empty(t, r.ReviewResult.ApprovedBy) } + func TestAwait_autoApprove(t *testing.T) { var suite testsuite.WorkflowTestSuite env := suite.NewTestWorkflowEnvironment() @@ -115,7 +115,6 @@ func TestAwait_autoApprove(t *testing.T) { err := env.GetWorkflowResult(&r) assert.NoError(t, err) - assert.Equal(t, r, res{ - Status: gate.Approved, - }) + assert.Equal(t, gate.Approved, r.ReviewResult.Status) + assert.Empty(t, r.ReviewResult.ApprovedBy) } diff --git a/server/neptune/workflows/internal/terraform/state/store.go b/server/neptune/workflows/internal/terraform/state/store.go index 45488d5b5..afcfd13c7 100644 --- a/server/neptune/workflows/internal/terraform/state/store.go +++ b/server/neptune/workflows/internal/terraform/state/store.go @@ -35,6 +35,8 @@ type UpdateOptions struct { ValidateSummary conftest.ValidateSummary StartTime time.Time EndTime time.Time + ApprovedBy string + ApprovedTime time.Time } func NewWorkflowStoreWithGenerator(notifier UpdateNotifier, g urlGenerator, mode terraform.WorkflowMode, id string) *WorkflowStore { @@ -165,6 +167,8 @@ func (s *WorkflowStore) UpdateApplyJobWithStatus(status JobStatus, options ...Up switch status { case InProgressJobStatus: s.state.Apply.StartTime = getStartTimeFromOpts(options...) + s.state.Apply.ApprovedBy = getApprovedByFromOpts(options...) + s.state.Apply.ApprovedTime = getApprovedTimeFromOpts(options...) case FailedJobStatus, SuccessJobStatus: s.state.Apply.EndTime = getEndTimeFromOpts(options...) @@ -200,3 +204,21 @@ func getEndTimeFromOpts(options ...UpdateOptions) time.Time { } return time.Time{} } + +func getApprovedByFromOpts(options ...UpdateOptions) string { + for _, o := range options { + if o.ApprovedBy != "" { + return o.ApprovedBy + } + } + return "" +} + +func getApprovedTimeFromOpts(options ...UpdateOptions) time.Time { + for _, o := range options { + if !o.ApprovedTime.IsZero() { + return o.ApprovedTime + } + } + return time.Time{} +} diff --git a/server/neptune/workflows/internal/terraform/state/workflow.go b/server/neptune/workflows/internal/terraform/state/workflow.go index 96a1cc9ab..ecfd68cae 100644 --- a/server/neptune/workflows/internal/terraform/state/workflow.go +++ b/server/neptune/workflows/internal/terraform/state/workflow.go @@ -82,16 +82,18 @@ type Job struct { Status JobStatus StartTime time.Time EndTime time.Time + ApprovedBy string + ApprovedTime time.Time } func (j *Job) toExternalJob() *plugins.JobState { return &plugins.JobState{ - ID: j.ID, - - // we can probably do this in a cleaner way - Status: plugins.JobStatus(string(j.Status)), - StartTime: j.StartTime, - EndTime: j.EndTime, + ID: j.ID, + Status: plugins.JobStatus(string(j.Status)), + StartTime: j.StartTime, + EndTime: j.EndTime, + ApprovedBy: j.ApprovedBy, + ApprovedTime: j.ApprovedTime, } } diff --git a/server/neptune/workflows/internal/terraform/workflow.go b/server/neptune/workflows/internal/terraform/workflow.go index 46f410ecc..6d1d36c23 100644 --- a/server/neptune/workflows/internal/terraform/workflow.go +++ b/server/neptune/workflows/internal/terraform/workflow.go @@ -241,13 +241,13 @@ func (r *Runner) Apply(ctx workflow.Context, root *terraform.LocalRoot, serverUR return errors.Wrap(err, "initializing job") } - planStatus, err := r.ReviewGate.Await(ctx, root.Root, planResponse.Summary) + reviewResult, err := r.ReviewGate.Await(ctx, root.Root, planResponse.Summary) if err != nil { workflow.GetLogger(ctx).Error("error waiting for plan review.", key.ErrKey, err) return newPlanRejectedError() } - if planStatus == gate.Rejected { + if reviewResult.Status == gate.Rejected { if err := r.Store.UpdateApplyJobWithStatus(state.RejectedJobStatus); err != nil { workflow.GetLogger(ctx).Error("unable to update job with rejected status.", key.ErrKey, err) } @@ -255,7 +255,9 @@ func (r *Runner) Apply(ctx workflow.Context, root *terraform.LocalRoot, serverUR } if err := r.Store.UpdateApplyJobWithStatus(state.InProgressJobStatus, state.UpdateOptions{ - StartTime: time.Now(), + StartTime: time.Now(), + ApprovedBy: reviewResult.ApprovedBy, + ApprovedTime: reviewResult.ApprovedTime, }); err != nil { return newUpdateJobError(err, "unable to update job with success status") } diff --git a/server/neptune/workflows/plugins/workflow_state.go b/server/neptune/workflows/plugins/workflow_state.go index d032244b4..50c5a8d6a 100644 --- a/server/neptune/workflows/plugins/workflow_state.go +++ b/server/neptune/workflows/plugins/workflow_state.go @@ -16,10 +16,12 @@ const ( // JobState represents the state of a job at a given time. type JobState struct { - ID string - Status JobStatus - StartTime time.Time - EndTime time.Time + ID string + Status JobStatus + StartTime time.Time + EndTime time.Time + ApprovedBy string + ApprovedTime time.Time } // TerraformWorkflowState contains the state of all jobs in the workflow