diff --git a/stack.go b/stack.go index 481b176fb..35cc7a929 100644 --- a/stack.go +++ b/stack.go @@ -74,16 +74,16 @@ type StackVCSRepo struct { // Stack represents a stack. type Stack struct { - ID string `jsonapi:"primary,stacks"` - Name string `jsonapi:"attr,name"` - Description string `jsonapi:"attr,description"` - DeploymentNames []string `jsonapi:"attr,deployment-names"` - VCSRepo *StackVCSRepo `jsonapi:"attr,vcs-repo"` - ErrorsCount int `jsonapi:"attr,errors-count"` - WarningsCount int `jsonapi:"attr,warnings-count"` - SpeculativePlansEnabled bool `jsonapi:"attr,speculative-enabled"` - CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` - UpdatedAt time.Time `jsonapi:"attr,updated-at,iso8601"` + ID string `jsonapi:"primary,stacks"` + Name string `jsonapi:"attr,name"` + Description string `jsonapi:"attr,description"` + DeploymentNames []string `jsonapi:"attr,deployment-names"` + VCSRepo *StackVCSRepo `jsonapi:"attr,vcs-repo"` + ErrorsCount int `jsonapi:"attr,errors-count"` + WarningsCount int `jsonapi:"attr,warnings-count"` + SpeculativeEnabled bool `jsonapi:"attr,speculative-enabled"` + CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` + UpdatedAt time.Time `jsonapi:"attr,updated-at,iso8601"` // Relationships Project *Project `jsonapi:"relation,project"` @@ -168,6 +168,22 @@ type StackUpdateOptions struct { VCSRepo *StackVCSRepo `jsonapi:"attr,vcs-repo,omitempty"` } +// WaitForStatusResult is the data structure that is sent over the channel +// returned by various status polling functions. For each result, either the +// Error or the Status will be set, but not both. If the Quit field is set, +// the channel will be closed. If the Quit field is set and the Error is +// nil, the Status field will be set to a specified quit status. +type WaitForStatusResult struct { + ID string + Status string + ReadAttempts int + Error error + Quit bool +} + +const minimumPollingIntervalMs = 3000 +const maximumPollingIntervalMs = 5000 + // UpdateConfiguration updates the configuration of a stack, triggering stack operations func (s *stacks) UpdateConfiguration(ctx context.Context, stackID string) (*Stack, error) { req, err := s.client.NewRequest("POST", fmt.Sprintf("stacks/%s/actions/update-configuration", url.PathEscape(stackID)), nil) @@ -289,3 +305,58 @@ func (s StackVCSRepo) valid() error { return nil } + +// awaitPoll is a helper function that uses a callback to read a status, then +// waits for a terminal status or an error. The callback should return the +// current status, or an error. For each time the status changes, the channel +// emits a new result. The id parameter should be the ID of the resource being +// polled, which is used in the result to help identify the resource being polled. +func awaitPoll(ctx context.Context, id string, reader func(ctx context.Context) (string, error), quitStatus []string) <-chan WaitForStatusResult { + resultCh := make(chan WaitForStatusResult) + + mapStatus := make(map[string]struct{}, len(quitStatus)) + for _, status := range quitStatus { + mapStatus[status] = struct{}{} + } + + go func() { + defer close(resultCh) + + reads := 0 + lastStatus := "" + for { + select { + case <-ctx.Done(): + resultCh <- WaitForStatusResult{ID: id, Error: fmt.Errorf("context canceled: %w", ctx.Err())} + return + case <-time.After(backoff(minimumPollingIntervalMs, maximumPollingIntervalMs, reads)): + status, err := reader(ctx) + if err != nil { + resultCh <- WaitForStatusResult{ID: id, Error: err, Quit: true} + return + } + + _, terminal := mapStatus[status] + + if status != lastStatus { + resultCh <- WaitForStatusResult{ + ID: id, + Status: status, + ReadAttempts: reads + 1, + Quit: terminal, + } + } + + lastStatus = status + + if terminal { + return + } + + reads += 1 + } + } + }() + + return resultCh +} diff --git a/stack_configuration.go b/stack_configuration.go index d8de15bd1..ce3251ab5 100644 --- a/stack_configuration.go +++ b/stack_configuration.go @@ -20,6 +20,33 @@ type StackConfigurations interface { // JSONSchemas returns a byte slice of the JSON schema for the stack configuration. JSONSchemas(ctx context.Context, stackConfigurationID string) ([]byte, error) + + // AwaitCompleted generates a channel that will receive the status of the + // stack configuration as it progresses, until that status is "converged", + // "converging", "errored", "canceled". + AwaitCompleted(ctx context.Context, stackConfigurationID string) <-chan WaitForStatusResult + + // AwaitPrepared generates a channel that will receive the status of the + // stack configuration as it progresses, until that status is "", + // "errored", "canceled". + AwaitStatus(ctx context.Context, stackConfigurationID string, status StackConfigurationStatus) <-chan WaitForStatusResult +} + +type StackConfigurationStatus string + +const ( + StackConfigurationStatusPending StackConfigurationStatus = "pending" + StackConfigurationStatusQueued StackConfigurationStatus = "queued" + StackConfigurationStatusPreparing StackConfigurationStatus = "preparing" + StackConfigurationStatusEnqueueing StackConfigurationStatus = "enqueueing" + StackConfigurationStatusConverged StackConfigurationStatus = "converged" + StackConfigurationStatusConverging StackConfigurationStatus = "converging" + StackConfigurationStatusErrored StackConfigurationStatus = "errored" + StackConfigurationStatusCanceled StackConfigurationStatus = "canceled" +) + +func (s StackConfigurationStatus) String() string { + return string(s) } type stackConfigurations struct { @@ -64,3 +91,33 @@ func (s stackConfigurations) JSONSchemas(ctx context.Context, stackConfiguration return raw.Bytes(), nil } + +// AwaitCompleted generates a channel that will receive the status of the stack configuration as it progresses. +// The channel will be closed when the stack configuration reaches a status indicating that or an error occurs. The +// read will be retried dependending on the configuration of the client. When the channel is closed, +// the last value will either be a completed status or an error. +func (s stackConfigurations) AwaitCompleted(ctx context.Context, stackConfigurationID string) <-chan WaitForStatusResult { + return awaitPoll(ctx, stackConfigurationID, func(ctx context.Context) (string, error) { + stackConfiguration, err := s.Read(ctx, stackConfigurationID) + if err != nil { + return "", err + } + + return stackConfiguration.Status, nil + }, []string{StackConfigurationStatusConverged.String(), StackConfigurationStatusConverging.String(), StackConfigurationStatusErrored.String(), StackConfigurationStatusCanceled.String()}) +} + +// AwaitStatus generates a channel that will receive the status of the stack configuration as it progresses. +// The channel will be closed when the stack configuration reaches a status indicating that or an error occurs. The +// read will be retried dependending on the configuration of the client. When the channel is closed, +// the last value will either be the specified status, "errored" status, or "canceled" status, or an error. +func (s stackConfigurations) AwaitStatus(ctx context.Context, stackConfigurationID string, status StackConfigurationStatus) <-chan WaitForStatusResult { + return awaitPoll(ctx, stackConfigurationID, func(ctx context.Context) (string, error) { + stackConfiguration, err := s.Read(ctx, stackConfigurationID) + if err != nil { + return "", err + } + + return stackConfiguration.Status, nil + }, []string{status.String(), StackConfigurationStatusErrored.String(), StackConfigurationStatusCanceled.String()}) +} diff --git a/stack_plan.go b/stack_plan.go index eed01f5f5..8edef8770 100644 --- a/stack_plan.go +++ b/stack_plan.go @@ -32,6 +32,14 @@ type StackPlans interface { // PlanDescription returns the plan description for a stack plan. PlanDescription(ctx context.Context, stackPlanID string) (*JSONChangeDesc, error) + + // AwaitTerminal generates a channel that will receive the status of the stack plan as it progresses. + // See WaitForStatusResult for more information. + AwaitTerminal(ctx context.Context, stackPlanID string) <-chan WaitForStatusResult + + // AwaitRunning generates a channel that will receive the status of the stack plan as it progresses. + // See WaitForStatusResult for more information. + AwaitRunning(ctx context.Context, stackPlanID string) <-chan WaitForStatusResult } type StackPlansStatusFilter string @@ -46,8 +54,29 @@ const ( StackPlansStatusFilterCanceled StackPlansStatusFilter = "canceled" ) +type StackPlanStatus string + +const ( + StackPlanStatusCreated StackPlanStatus = "created" + StackPlanStatusRunning StackPlanStatus = "running" + StackPlanStatusRunningQueued StackPlanStatus = "running_queued" + StackPlanStatusRunningPlanning StackPlanStatus = "running_planning" + StackPlanStatusRunningApplying StackPlanStatus = "running_applying" + StackPlanStatusFinished StackPlanStatus = "finished" + StackPlanStatusFinishedNoChanges StackPlanStatus = "finished_no_changes" + StackPlanStatusFinishedPlanned StackPlanStatus = "finished_planned" + StackPlanStatusFinishedApplied StackPlanStatus = "finished_applied" + StackPlanStatusDiscarded StackPlanStatus = "discarded" + StackPlanStatusErrored StackPlanStatus = "errored" + StackPlanStatusCanceled StackPlanStatus = "canceled" +) + type StackPlansIncludeOpt string +func (s StackPlanStatus) String() string { + return string(s) +} + const ( StackPlansIncludeOperations StackPlansIncludeOpt = "stack_plan_operations" ) @@ -98,7 +127,7 @@ type StackPlan struct { ID string `jsonapi:"primary,stack-plans"` PlanMode string `jsonapi:"attr,plan-mode"` PlanNumber string `jsonapi:"attr,plan-number"` - Status string `jsonapi:"attr,status"` + Status StackPlanStatus `jsonapi:"attr,status"` StatusTimestamps *StackPlanStatusTimestamps `jsonapi:"attr,status-timestamps"` IsPlanned bool `jsonapi:"attr,is-planned"` Changes *PlanChanges `jsonapi:"attr,changes"` @@ -261,3 +290,56 @@ func (s stackPlans) PlanDescription(ctx context.Context, stackPlanID string) (*J return jd, nil } + +// AwaitTerminal generates a channel that will receive the status of the stack plan as it progresses. +// The channel will be closed when the stack plan reaches a final status or an error occurs. The +// read will be retried dependending on the configuration of the client. When the channel is closed, +// the last value will either be a terminal status (finished, finished_no_changes, finished_applied, +// finished_planned, discarded, canceled, errorer), or an error. The status check will continue even +// if the stack plan is waiting for approval. Check the status within the the channel to determine +// if the stack plan needs approval. +func (s stackPlans) AwaitTerminal(ctx context.Context, stackPlanID string) <-chan WaitForStatusResult { + return awaitPoll(ctx, stackPlanID, func(ctx context.Context) (string, error) { + stackPlan, err := s.Read(ctx, stackPlanID) + if err != nil { + return "", err + } + + return stackPlan.Status.String(), nil + }, []string{ + StackPlanStatusFinished.String(), + StackPlanStatusFinishedNoChanges.String(), + StackPlanStatusFinishedApplied.String(), + StackPlanStatusFinishedPlanned.String(), + StackPlanStatusDiscarded.String(), + StackPlanStatusErrored.String(), + StackPlanStatusCanceled.String(), + }) +} + +// AwaitRunning generates a channel that will receive the status of the stack plan as it progresses. +// The channel will be closed when the stack plan reaches a running status (running, running_queued, +// running_planning, running_applying), a terminal status (finished, finished_no_changes, finished_applied, +// finished_planned, discarded, canceled, errorer), or an error occurs. The read will be retried +// dependending on the configuration of the client. +func (s stackPlans) AwaitRunning(ctx context.Context, stackPlanID string) <-chan WaitForStatusResult { + return awaitPoll(ctx, stackPlanID, func(ctx context.Context) (string, error) { + stackPlan, err := s.Read(ctx, stackPlanID) + if err != nil { + return "", err + } + + return stackPlan.Status.String(), nil + }, []string{ + StackPlanStatusRunning.String(), + StackPlanStatusRunningPlanning.String(), + StackPlanStatusRunningApplying.String(), + StackPlanStatusFinished.String(), + StackPlanStatusFinishedNoChanges.String(), + StackPlanStatusFinishedApplied.String(), + StackPlanStatusFinishedPlanned.String(), + StackPlanStatusDiscarded.String(), + StackPlanStatusErrored.String(), + StackPlanStatusCanceled.String(), + }) +}