github.com/defang-io/defang/src@v0.0.0-20240505002154-bdf411911834/pkg/clouds/aws/ecs/cfn/setup.go (about)

     1  package cfn
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"strings"
     8  	"time"
     9  
    10  	"github.com/aws/aws-sdk-go-v2/service/cloudformation"
    11  	cfnTypes "github.com/aws/aws-sdk-go-v2/service/cloudformation/types"
    12  	"github.com/aws/smithy-go"
    13  	"github.com/aws/smithy-go/ptr"
    14  	common "github.com/defang-io/defang/src/pkg/clouds/aws"
    15  	"github.com/defang-io/defang/src/pkg/clouds/aws/ecs"
    16  	"github.com/defang-io/defang/src/pkg/clouds/aws/ecs/cfn/outputs"
    17  	"github.com/defang-io/defang/src/pkg/clouds/aws/region"
    18  	"github.com/defang-io/defang/src/pkg/types"
    19  )
    20  
    21  type AwsEcs struct {
    22  	ecs.AwsEcs
    23  	stackName string
    24  }
    25  
    26  // var _ types.Driver = (*AwsEcs)(nil)
    27  
    28  const stackTimeout = time.Minute * 3
    29  
    30  func OptionVPCAndSubnetID(ctx context.Context, vpcID, subnetID string) func(types.Driver) error {
    31  	return func(d types.Driver) error {
    32  		if ecs, ok := d.(*AwsEcs); ok {
    33  			return ecs.PopulateVPCandSubnetID(ctx, vpcID, subnetID)
    34  		}
    35  		return errors.New("only AwsEcs driver supports VPC ID and Subnet ID option")
    36  	}
    37  }
    38  
    39  func New(stack string, region region.Region) *AwsEcs {
    40  	if stack == "" {
    41  		panic("stack must be set")
    42  	}
    43  	return &AwsEcs{
    44  		stackName: stack,
    45  		AwsEcs: ecs.AwsEcs{
    46  			Aws:  common.Aws{Region: region},
    47  			Spot: true,
    48  		},
    49  	}
    50  }
    51  
    52  func (a *AwsEcs) newClient(ctx context.Context) (*cloudformation.Client, error) {
    53  	cfg, err := a.LoadConfig(ctx)
    54  	if err != nil {
    55  		return nil, err
    56  	}
    57  
    58  	return cloudformation.NewFromConfig(cfg), nil
    59  }
    60  
    61  // update1s is a functional option for cloudformation.StackUpdateCompleteWaiter that sets the MinDelay to 1
    62  func update1s(o *cloudformation.StackUpdateCompleteWaiterOptions) {
    63  	o.MinDelay = 1
    64  }
    65  
    66  func (a *AwsEcs) updateStackAndWait(ctx context.Context, templateBody string) error {
    67  	cfn, err := a.newClient(ctx)
    68  	if err != nil {
    69  		return err
    70  	}
    71  
    72  	uso, err := cfn.UpdateStack(ctx, &cloudformation.UpdateStackInput{
    73  		StackName:    ptr.String(a.stackName),
    74  		TemplateBody: ptr.String(templateBody),
    75  		Capabilities: []cfnTypes.Capability{cfnTypes.CapabilityCapabilityNamedIam},
    76  	})
    77  	if err != nil {
    78  		// Go SDK doesn't have --no-fail-on-empty-changeset; ignore ValidationError: No updates are to be performed.
    79  		var apiError smithy.APIError
    80  		if ok := errors.As(err, &apiError); ok && apiError.ErrorCode() == "ValidationError" && apiError.ErrorMessage() == "No updates are to be performed." {
    81  			return a.FillOutputs(ctx)
    82  		}
    83  		// TODO: handle UPDATE_COMPLETE_CLEANUP_IN_PROGRESS
    84  		return err // might call createStackAndWait depending on the error
    85  	}
    86  
    87  	fmt.Println("Waiting for stack", a.stackName, "to be updated...") // TODO: verbose only
    88  	o, err := cloudformation.NewStackUpdateCompleteWaiter(cfn, update1s).WaitForOutput(ctx, &cloudformation.DescribeStacksInput{
    89  		StackName: uso.StackId,
    90  	}, stackTimeout)
    91  	if err != nil {
    92  		return err
    93  	}
    94  	return a.fillWithOutputs(o)
    95  }
    96  
    97  // create1s is a functional option for cloudformation.StackCreateCompleteWaiter that sets the MinDelay to 1
    98  func create1s(o *cloudformation.StackCreateCompleteWaiterOptions) {
    99  	o.MinDelay = 1
   100  }
   101  
   102  func (a *AwsEcs) createStackAndWait(ctx context.Context, templateBody string) error {
   103  	cfn, err := a.newClient(ctx)
   104  	if err != nil {
   105  		return err
   106  	}
   107  
   108  	_, err = cfn.CreateStack(ctx, &cloudformation.CreateStackInput{
   109  		StackName:    ptr.String(a.stackName),
   110  		TemplateBody: ptr.String(templateBody),
   111  		Capabilities: []cfnTypes.Capability{cfnTypes.CapabilityCapabilityNamedIam},
   112  		OnFailure:    cfnTypes.OnFailureDelete,
   113  	})
   114  	if err != nil {
   115  		// Ignore AlreadyExistsException; return all other errors
   116  		var alreadyExists *cfnTypes.AlreadyExistsException
   117  		if !errors.As(err, &alreadyExists) {
   118  			return err
   119  		}
   120  	}
   121  
   122  	fmt.Println("Waiting for stack", a.stackName, "to be created...") // TODO: verbose only
   123  	dso, err := cloudformation.NewStackCreateCompleteWaiter(cfn, create1s).WaitForOutput(ctx, &cloudformation.DescribeStacksInput{
   124  		StackName: ptr.String(a.stackName),
   125  	}, stackTimeout)
   126  	if err != nil {
   127  		return err
   128  	}
   129  	return a.fillWithOutputs(dso)
   130  }
   131  
   132  func (a *AwsEcs) SetUp(ctx context.Context, containers []types.Container) error {
   133  	template, err := createTemplate(a.stackName, containers, TemplateOverrides{VpcID: a.VpcID}, a.Spot).YAML()
   134  	if err != nil {
   135  		return err
   136  	}
   137  
   138  	// Upsert
   139  	if err := a.updateStackAndWait(ctx, string(template)); err != nil {
   140  		// Check if the stack doesn't exist; if so, create it, otherwise return the error
   141  		var apiError smithy.APIError
   142  		if ok := errors.As(err, &apiError); !ok || (apiError.ErrorCode() != "ValidationError") || !strings.HasSuffix(apiError.ErrorMessage(), "does not exist") {
   143  			return err
   144  		}
   145  		return a.createStackAndWait(ctx, string(template))
   146  	}
   147  	return nil
   148  }
   149  
   150  func (a *AwsEcs) FillOutputs(ctx context.Context) error {
   151  	// println("Filling outputs for stack", stackId)
   152  	cfn, err := a.newClient(ctx)
   153  	if err != nil {
   154  		return err
   155  	}
   156  
   157  	// FIXME: this always returns the latest outputs, not the ones from the recent update
   158  	dso, err := cfn.DescribeStacks(ctx, &cloudformation.DescribeStacksInput{
   159  		StackName: &a.stackName,
   160  	})
   161  	if err != nil {
   162  		return err
   163  	}
   164  	return a.fillWithOutputs(dso)
   165  }
   166  
   167  func (a *AwsEcs) fillWithOutputs(dso *cloudformation.DescribeStacksOutput) error {
   168  	for _, stack := range dso.Stacks {
   169  		for _, output := range stack.Outputs {
   170  			switch *output.OutputKey {
   171  			case outputs.SubnetID:
   172  				if a.SubNetID == "" {
   173  					a.SubNetID = *output.OutputValue
   174  				}
   175  			case outputs.TaskDefArn:
   176  				if a.TaskDefARN == "" {
   177  					a.TaskDefARN = *output.OutputValue
   178  				}
   179  			case outputs.ClusterName:
   180  				a.ClusterName = *output.OutputValue
   181  			case outputs.LogGroupARN:
   182  				a.LogGroupARN = *output.OutputValue
   183  			case outputs.SecurityGroupID:
   184  				a.SecurityGroupID = *output.OutputValue
   185  			case outputs.BucketName:
   186  				a.BucketName = *output.OutputValue
   187  				// default:; TODO: should do this but only for stack the driver created
   188  				// 	return fmt.Errorf("unknown output key %q", *output.OutputKey)
   189  			}
   190  		}
   191  	}
   192  
   193  	return nil
   194  }
   195  
   196  func (a *AwsEcs) Run(ctx context.Context, env map[string]string, cmd ...string) (ecs.TaskArn, error) {
   197  	if err := a.FillOutputs(ctx); err != nil {
   198  		return nil, err
   199  	}
   200  
   201  	return a.AwsEcs.Run(ctx, env, cmd...)
   202  }
   203  
   204  func (a *AwsEcs) Tail(ctx context.Context, taskArn ecs.TaskArn) error {
   205  	if err := a.FillOutputs(ctx); err != nil {
   206  		return err
   207  	}
   208  	return a.AwsEcs.Tail(ctx, taskArn)
   209  }
   210  
   211  func (a *AwsEcs) Stop(ctx context.Context, taskArn ecs.TaskArn) error {
   212  	if err := a.FillOutputs(ctx); err != nil {
   213  		return err
   214  	}
   215  	return a.AwsEcs.Stop(ctx, taskArn)
   216  }
   217  
   218  func (a *AwsEcs) GetInfo(ctx context.Context, taskArn ecs.TaskArn) (*types.TaskInfo, error) {
   219  	if err := a.FillOutputs(ctx); err != nil {
   220  		return nil, err
   221  	}
   222  	return a.AwsEcs.Info(ctx, taskArn)
   223  }
   224  
   225  func (a *AwsEcs) TearDown(ctx context.Context) error {
   226  	cfn, err := a.newClient(ctx)
   227  	if err != nil {
   228  		return err
   229  	}
   230  
   231  	_, err = cfn.DeleteStack(ctx, &cloudformation.DeleteStackInput{
   232  		StackName: ptr.String(a.stackName),
   233  		// RetainResources: []string{"Bucket"}, only when the stack is in the DELETE_FAILED state
   234  	})
   235  	if err != nil {
   236  		return err
   237  	}
   238  
   239  	fmt.Println("Waiting for stack", a.stackName, "to be deleted...") // TODO: verbose only
   240  	return cloudformation.NewStackDeleteCompleteWaiter(cfn, delete1s).Wait(ctx, &cloudformation.DescribeStacksInput{
   241  		StackName: ptr.String(a.stackName),
   242  	}, stackTimeout)
   243  }
   244  
   245  // delete1s is a functional option for cloudformation.StackDeleteCompleteWaiter that sets the MinDelay to 1
   246  func delete1s(o *cloudformation.StackDeleteCompleteWaiterOptions) {
   247  	o.MinDelay = 1
   248  }