platform/lambda/stack/stack.go
// Package stack provides CloudFormation stack support.
package stack
import (
"encoding/json"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/apigateway"
"github.com/aws/aws-sdk-go/service/cloudformation"
"github.com/aws/aws-sdk-go/service/lambda"
"github.com/aws/aws-sdk-go/service/route53"
"github.com/pkg/errors"
"github.com/apex/log"
"github.com/apex/up"
"github.com/apex/up/config"
"github.com/apex/up/internal/util"
"github.com/apex/up/platform/event"
"github.com/apex/up/platform/lambda/stack/resources"
)
// TODO: refactor a lot
// TODO: backoff
// TODO: profile changeset name and description flags
// TODO: flags for changeset name / description
// defaultChangeset name.
var defaultChangeset = "changes"
// Map type.
type Map = resources.Map
// Stack represents a single CloudFormation stack.
type Stack struct {
client *cloudformation.CloudFormation
lambda *lambda.Lambda
route53 *route53.Route53
apigateway *apigateway.APIGateway
events event.Events
zones []*route53.HostedZone
config *up.Config
}
// New stack.
func New(c *up.Config, events event.Events, zones []*route53.HostedZone, region string) *Stack {
sess := session.New(aws.NewConfig().WithRegion(region))
return &Stack{
client: cloudformation.New(sess),
lambda: lambda.New(sess),
route53: route53.New(sess),
apigateway: apigateway.New(sess),
events: events,
zones: zones,
config: c,
}
}
// template returns a configured resource template.
func (s *Stack) template(versions resources.Versions) Map {
return resources.New(&resources.Config{
Config: s.config,
Zones: s.zones,
Versions: versions,
})
}
// Create the stack.
func (s *Stack) Create(versions resources.Versions) error {
c := s.config
tmpl := s.template(versions)
name := c.Name
b, err := json.MarshalIndent(tmpl, "", " ")
if err != nil {
return errors.Wrap(err, "marshaling")
}
_, err = s.client.CreateStack(&cloudformation.CreateStackInput{
StackName: &name,
TemplateBody: aws.String(string(b)),
TimeoutInMinutes: aws.Int64(60),
DisableRollback: aws.Bool(true),
Capabilities: aws.StringSlice([]string{"CAPABILITY_NAMED_IAM"}),
Parameters: []*cloudformation.Parameter{
{
ParameterKey: aws.String("Name"),
ParameterValue: &name,
},
{
ParameterKey: aws.String("FunctionName"),
ParameterValue: &name,
},
},
})
if err != nil {
return errors.Wrap(err, "creating stack")
}
if err := s.report(resourceStateFromTemplate(tmpl, CreateComplete)); err != nil {
return errors.Wrap(err, "reporting")
}
stack, err := s.getStack()
if err != nil {
return errors.Wrap(err, "fetching stack")
}
status := Status(*stack.StackStatus)
if status.State() == Failure {
return errors.New(*stack.StackStatusReason)
}
return nil
}
// Delete the stack, optionally waiting for completion.
func (s *Stack) Delete(versions resources.Versions, wait bool) error {
_, err := s.client.DeleteStack(&cloudformation.DeleteStackInput{
StackName: &s.config.Name,
})
if err != nil {
return errors.Wrap(err, "deleting")
}
if wait {
tmpl := s.template(versions)
if err := s.report(resourceStateFromTemplate(tmpl, DeleteComplete)); err != nil {
return errors.Wrap(err, "reporting")
}
}
return nil
}
// Show resources.
func (s *Stack) Show() error {
defer s.events.Time("platform.stack.show", nil)()
// show stack status
stack, err := s.getStack()
if err != nil {
return errors.Wrap(err, "fetching stack")
}
s.events.Emit("platform.stack.show.stack", event.Fields{
"stack": stack,
})
// stages
for _, stage := range s.config.Stages.List() {
if stage.Domain == "" {
continue
}
s.events.Emit("platform.stack.show.stage", event.Fields{
"name": stage.Name,
"domain": stage.Domain,
})
// show cloudfront endpoint
if err := s.showCloudfront(stage); err != nil {
log.WithError(err).Debug("showing cloudfront")
}
// show function version
if err := s.showVersion(stage); err != nil {
log.WithError(err).Debug("showing version")
}
// show nameservers
if err := s.showNameservers(stage); err != nil {
return errors.Wrap(err, "showing nameservers")
}
}
// skip events if everything is ok
if Status(*stack.StackStatus).State() == Success {
return nil
}
// show events
s.events.Emit("platform.stack.show.stack.events", nil)
events, err := s.getFailedEvents()
if err != nil {
return errors.Wrap(err, "fetching latest events")
}
for _, e := range events {
if *e.LogicalResourceId == s.config.Name {
continue
}
s.events.Emit("platform.stack.show.stack.event", event.Fields{
"event": e,
})
}
return nil
}
// Plan changes.
func (s *Stack) Plan(versions resources.Versions) error {
c := s.config
tmpl := s.template(versions)
name := c.Name
b, err := json.MarshalIndent(tmpl, "", " ")
if err != nil {
return errors.Wrap(err, "marshaling")
}
defer s.events.Time("platform.stack.plan", nil)
log.Debug("deleting changeset")
_, err = s.client.DeleteChangeSet(&cloudformation.DeleteChangeSetInput{
StackName: &name,
ChangeSetName: &defaultChangeset,
})
if err != nil {
return errors.Wrap(err, "deleting changeset")
}
log.Debug("creating changeset")
_, err = s.client.CreateChangeSet(&cloudformation.CreateChangeSetInput{
StackName: &name,
ChangeSetName: &defaultChangeset,
TemplateBody: aws.String(string(b)),
Capabilities: aws.StringSlice([]string{"CAPABILITY_NAMED_IAM"}),
ChangeSetType: aws.String("UPDATE"),
Description: aws.String("Managed by Up."),
Parameters: []*cloudformation.Parameter{
{
ParameterKey: aws.String("Name"),
ParameterValue: &name,
},
{
ParameterKey: aws.String("FunctionName"),
ParameterValue: &name,
},
},
})
if err != nil {
return errors.Wrap(err, "creating changeset")
}
var next *string
for {
log.Debug("describing changeset")
res, err := s.client.DescribeChangeSet(&cloudformation.DescribeChangeSetInput{
StackName: &name,
ChangeSetName: &defaultChangeset,
NextToken: next,
})
if err != nil {
return errors.Wrap(err, "describing changeset")
}
status := Status(*res.Status)
if status.State() == Failure {
if _, err := s.client.DeleteChangeSet(&cloudformation.DeleteChangeSetInput{
StackName: &name,
ChangeSetName: &defaultChangeset,
}); err != nil {
return errors.Wrap(err, "deleting changeset")
}
return errors.New(*res.StatusReason)
}
if !status.IsDone() {
log.Debug("waiting for completion")
time.Sleep(750 * time.Millisecond)
continue
}
for _, c := range res.Changes {
s.events.Emit("platform.stack.plan.change", event.Fields{
"change": c,
})
}
next = res.NextToken
if next == nil {
break
}
}
return nil
}
// Apply changes.
func (s *Stack) Apply() error {
c := s.config
name := c.Name
res, err := s.client.DescribeChangeSet(&cloudformation.DescribeChangeSetInput{
StackName: &name,
ChangeSetName: &defaultChangeset,
})
if isNotFound(err) {
return errors.Errorf("changeset does not exist, run `up stack plan` first")
}
if err != nil {
return errors.Wrap(err, "describing changeset")
}
defer s.events.Time("platform.stack.apply", event.Fields{
"changes": len(res.Changes),
})()
_, err = s.client.ExecuteChangeSet(&cloudformation.ExecuteChangeSetInput{
StackName: &name,
ChangeSetName: &defaultChangeset,
})
if err != nil {
return errors.Wrap(err, "executing changeset")
}
if err := s.report(resourceStateFromChanges(res.Changes)); err != nil {
return errors.Wrap(err, "reporting")
}
return nil
}
// report events with a map of desired stats from logical or physical id,
// any resources not mapped are ignored as they do not contribute to changes.
func (s *Stack) report(states map[string]Status) error {
defer s.events.Time("platform.stack.report", event.Fields{
"total": len(states),
"complete": 0,
})()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
stack, err := s.getStack()
if util.IsNotFound(err) {
return nil
}
if util.IsThrottled(err) {
time.Sleep(3 * time.Second)
continue
}
if err != nil {
return errors.Wrap(err, "fetching stack")
}
status := Status(*stack.StackStatus)
if status.IsDone() {
return nil
}
res, err := s.client.DescribeStackResources(&cloudformation.DescribeStackResourcesInput{
StackName: &s.config.Name,
})
if util.IsThrottled(err) {
time.Sleep(time.Second * 3)
continue
}
if err != nil {
return errors.Wrap(err, "describing stack resources")
}
complete := len(resourcesCompleted(res.StackResources, states))
s.events.Emit("platform.stack.report.event", event.Fields{
"total": len(states),
"complete": complete,
})
}
return nil
}
// showVersion emits events for showing the Lambda version.
func (s *Stack) showVersion(stage *config.Stage) error {
res, err := s.lambda.GetAlias(&lambda.GetAliasInput{
FunctionName: &s.config.Name,
Name: &stage.Name,
})
if err != nil {
return errors.Wrap(err, "fetching alias")
}
s.events.Emit("platform.stack.show.version", event.Fields{
"domain": stage.Domain,
"version": *res.FunctionVersion,
})
return nil
}
// showCloudfront emits events for listing cloudfront end-points.
func (s *Stack) showCloudfront(stage *config.Stage) error {
if stage.Domain == "" {
return nil
}
res, err := s.apigateway.GetDomainName(&apigateway.GetDomainNameInput{
DomainName: &stage.Domain,
})
if err != nil {
return errors.Wrap(err, "getting domain mapping")
}
if res.DistributionDomainName == nil {
return nil
}
s.events.Emit("platform.stack.show.domain", event.Fields{
"domain": stage.Domain,
"endpoint": *res.DistributionDomainName,
})
return nil
}
// showNameservers emits events for listing name servers.
func (s *Stack) showNameservers(stage *config.Stage) error {
if stage.Domain == "" {
return nil
}
res, err := s.route53.ListHostedZonesByName(&route53.ListHostedZonesByNameInput{
DNSName: &stage.Domain,
MaxItems: aws.String("1"),
})
if err != nil {
return errors.Wrap(err, "listing hosted zone")
}
if len(res.HostedZones) == 0 {
return nil
}
z := res.HostedZones[0]
if stage.Domain+"." != *z.Name {
return nil
}
zone, err := s.route53.GetHostedZone(&route53.GetHostedZoneInput{
Id: z.Id,
})
if err != nil {
return errors.Wrap(err, "fetching hosted zone")
}
var ns []string
for _, s := range zone.DelegationSet.NameServers {
ns = append(ns, *s)
}
s.events.Emit("platform.stack.show.nameservers", event.Fields{
"nameservers": ns,
})
return nil
}
// getStack returns the stack.
func (s *Stack) getStack() (*cloudformation.Stack, error) {
res, err := s.client.DescribeStacks(&cloudformation.DescribeStacksInput{
StackName: &s.config.Name,
})
if err != nil {
return nil, err
}
stack := res.Stacks[0]
return stack, nil
}
// getLatestEvents returns the latest events for each resource.
func (s *Stack) getLatestEvents() (v []*cloudformation.StackEvent, err error) {
events, err := s.getEvents()
if err != nil {
return
}
hit := make(map[string]bool)
for _, e := range events {
id := *e.LogicalResourceId
if hit[id] {
continue
}
hit[id] = true
v = append(v, e)
}
return
}
// getFailedEvents returns failed events.
func (s *Stack) getFailedEvents() (v []*cloudformation.StackEvent, err error) {
events, err := s.getEvents()
if err != nil {
return
}
for _, e := range events {
if Status(*e.ResourceStatus).State() == Failure {
v = append(v, e)
}
}
return
}
// getEvents returns events.
func (s *Stack) getEvents() (events []*cloudformation.StackEvent, err error) {
var next *string
for {
res, err := s.client.DescribeStackEvents(&cloudformation.DescribeStackEventsInput{
StackName: &s.config.Name,
NextToken: next,
})
if err != nil {
return nil, err
}
events = append(events, res.StackEvents...)
next = res.NextToken
if next == nil {
break
}
}
return
}
// resourceStateFromTemplate returns a map of the logical ids from template t, to status s.
func resourceStateFromTemplate(t Map, s Status) map[string]Status {
r := t["Resources"].(Map)
m := make(map[string]Status)
for id := range r {
m[id] = s
}
return m
}
// TODO: ignore deletes since they're in cleanup phase?
// resourceStateFromChanges returns a map of statuses from a changeset.
func resourceStateFromChanges(changes []*cloudformation.Change) map[string]Status {
m := make(map[string]Status)
for _, c := range changes {
var state Status
var id string
if s := c.ResourceChange.PhysicalResourceId; s != nil {
id = *s
}
if id == "" {
id = *c.ResourceChange.LogicalResourceId
}
switch a := *c.ResourceChange.Action; a {
case "Add":
state = CreateComplete
case "Modify":
state = UpdateComplete
case "Remove":
state = DeleteComplete
default:
panic(errors.Errorf("unhandled Action %q", a))
}
m[id] = state
}
return m
}
// resourcesCompleted returns a map of the completed resources. When the resource is not
// present in states, it is ignored as no changes are expected.
func resourcesCompleted(resources []*cloudformation.StackResource, states map[string]Status) map[string]*cloudformation.StackResource {
m := make(map[string]*cloudformation.StackResource)
for _, r := range resources {
var expected Status
var id string
// try physical id first, this is necessary as
// replacement of a logical id will cause the id
// to appear twice (once for Add once for Remove).
if s := r.PhysicalResourceId; s != nil {
if _, ok := states[*s]; ok {
id = *s
}
}
// try logical id
if s := *r.LogicalResourceId; id == "" {
if _, ok := states[s]; ok {
id = s
}
}
// expected state
if id != "" {
expected = states[id]
}
// matched expected state
if expected == Status(*r.ResourceStatus) {
m[id] = r
}
}
return m
}
// isNotFound returns true if the error indicates a missing changeset.
func isNotFound(err error) bool {
return err != nil && strings.Contains(err.Error(), "ChangeSetNotFound")
}