github.com/defang-io/defang/src@v0.0.0-20240505002154-bdf411911834/pkg/cli/client/byoc/clouds/aws.go (about)

     1  package clouds
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/base64"
     7  	"errors"
     8  	"fmt"
     9  	"io"
    10  	"net"
    11  	"os"
    12  	"sort"
    13  	"strings"
    14  	"time"
    15  
    16  	aws2 "github.com/aws/aws-sdk-go-v2/aws"
    17  	"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
    18  	"github.com/aws/aws-sdk-go-v2/service/route53"
    19  	types2 "github.com/aws/aws-sdk-go-v2/service/route53/types"
    20  	"github.com/aws/aws-sdk-go-v2/service/s3"
    21  	"github.com/aws/aws-sdk-go-v2/service/sts"
    22  	"github.com/aws/smithy-go/ptr"
    23  	"github.com/bufbuild/connect-go"
    24  	compose "github.com/compose-spec/compose-go/v2/types"
    25  	"github.com/defang-io/defang/src/pkg"
    26  	"github.com/defang-io/defang/src/pkg/cli/client"
    27  	"github.com/defang-io/defang/src/pkg/clouds/aws"
    28  	"github.com/defang-io/defang/src/pkg/clouds/aws/ecs"
    29  	"github.com/defang-io/defang/src/pkg/clouds/aws/ecs/cfn"
    30  	"github.com/defang-io/defang/src/pkg/http"
    31  	"github.com/defang-io/defang/src/pkg/quota"
    32  	"github.com/defang-io/defang/src/pkg/term"
    33  	"github.com/defang-io/defang/src/pkg/types"
    34  	defangv1 "github.com/defang-io/defang/src/protos/io/defang/v1"
    35  	"google.golang.org/protobuf/proto"
    36  )
    37  
    38  type ByocAws struct {
    39  	*client.GrpcClient
    40  
    41  	cdTasks                 map[string]ecs.TaskArn
    42  	customDomain            string // TODO: Not BYOD domain which is per service, should rename to something like delegated defang domain
    43  	driver                  *cfn.AwsEcs
    44  	privateDomain           string
    45  	privateLbIps            []string
    46  	publicNatIps            []string
    47  	pulumiProject           string
    48  	pulumiStack             string
    49  	quota                   quota.Quotas
    50  	setupDone               bool
    51  	tenantID                string
    52  	shouldDelegateSubdomain bool
    53  }
    54  
    55  var _ client.Client = (*ByocAws)(nil)
    56  
    57  func NewByocAWS(tenantId types.TenantID, defClient *client.GrpcClient) *ByocAws {
    58  	b := &ByocAws{
    59  		GrpcClient:   defClient,
    60  		cdTasks:      make(map[string]ecs.TaskArn),
    61  		customDomain: "",
    62  		driver:       cfn.New(CdTaskPrefix, aws.Region("")), // default region
    63  		pulumiStack:  "beta",                                // TODO: make customizable
    64  		quota: quota.Quotas{
    65  			// These serve mostly to pevent fat-finger errors in the CLI or Compose files
    66  			Cpus:       16,
    67  			Gpus:       8,
    68  			MemoryMiB:  65536,
    69  			Replicas:   16,
    70  			Services:   40,
    71  			ShmSizeMiB: 30720,
    72  		},
    73  		tenantID: string(tenantId),
    74  		// privateLbIps:  nil,                                                 // TODO: grab these from the AWS API or outputs
    75  		// publicNatIps:  nil,                                                 // TODO: grab these from the AWS API or outputs
    76  	}
    77  	return b
    78  }
    79  
    80  func (b *ByocAws) LoadProject() (*compose.Project, error) {
    81  	var proj *compose.Project
    82  	var err error
    83  	projectNameOverride := os.Getenv("COMPOSE_PROJECT_NAME") // overrides the project name, except in the playground env
    84  	loader := b.GrpcClient.Loader
    85  	if projectNameOverride != "" {
    86  		proj, err = loader.LoadWithProjectName(projectNameOverride)
    87  	} else {
    88  		proj, err = loader.LoadWithDefaultProjectName(b.tenantID)
    89  	}
    90  	if err != nil {
    91  		return nil, err
    92  	}
    93  	b.privateDomain = dnsSafeLabel(proj.Name) + ".internal"
    94  	b.pulumiProject = proj.Name
    95  	return proj, nil
    96  }
    97  
    98  func (b *ByocAws) setUp(ctx context.Context) error {
    99  	if b.setupDone {
   100  		return nil
   101  	}
   102  	cdTaskName := CdTaskPrefix
   103  	containers := []types.Container{
   104  		{
   105  			Image:     "public.ecr.aws/pulumi/pulumi-nodejs:latest",
   106  			Name:      ecs.ContainerName,
   107  			Cpus:      2.0,
   108  			Memory:    2048_000_000, // 2G
   109  			Essential: ptr.Bool(true),
   110  			VolumesFrom: []string{
   111  				cdTaskName,
   112  			},
   113  			WorkDir:    ptr.String("/app"),
   114  			DependsOn:  map[string]types.ContainerCondition{cdTaskName: "START"},
   115  			EntryPoint: []string{"node", "lib/index.js"},
   116  		},
   117  		{
   118  			Image:     CdImage,
   119  			Name:      cdTaskName,
   120  			Essential: ptr.Bool(false),
   121  			Volumes: []types.TaskVolume{
   122  				{
   123  					Source:   "pulumi-plugins",
   124  					Target:   "/root/.pulumi/plugins",
   125  					ReadOnly: true,
   126  				},
   127  				{
   128  					Source:   "cd",
   129  					Target:   "/app",
   130  					ReadOnly: true,
   131  				},
   132  			},
   133  		},
   134  	}
   135  	if err := b.driver.SetUp(ctx, containers); err != nil {
   136  		return annotateAwsError(err)
   137  	}
   138  
   139  	if b.customDomain == "" {
   140  		domain, err := b.GetDelegateSubdomainZone(ctx)
   141  		if err != nil {
   142  			// return err; FIXME: ignore this error for now
   143  		} else {
   144  			b.customDomain = b.getProjectDomain(domain.Zone)
   145  			b.shouldDelegateSubdomain = true
   146  		}
   147  	}
   148  
   149  	b.setupDone = true
   150  	return nil
   151  }
   152  
   153  func (b *ByocAws) Deploy(ctx context.Context, req *defangv1.DeployRequest) (*defangv1.DeployResponse, error) {
   154  	if err := b.setUp(ctx); err != nil {
   155  		return nil, err
   156  	}
   157  
   158  	etag := pkg.RandomID()
   159  	if len(req.Services) > b.quota.Services {
   160  		return nil, errors.New("maximum number of services reached")
   161  	}
   162  	serviceInfos := []*defangv1.ServiceInfo{}
   163  	for _, service := range req.Services {
   164  		serviceInfo, err := b.update(ctx, service)
   165  		if err != nil {
   166  			return nil, err
   167  		}
   168  		serviceInfo.Etag = etag // same etag for all services
   169  		serviceInfos = append(serviceInfos, serviceInfo)
   170  	}
   171  
   172  	// Ensure all service endpoints are unique
   173  	endpoints := make(map[string]bool)
   174  	for _, serviceInfo := range serviceInfos {
   175  		for _, endpoint := range serviceInfo.Endpoints {
   176  			if endpoints[endpoint] {
   177  				return nil, fmt.Errorf("duplicate endpoint: %s", endpoint) // CodeInvalidArgument
   178  			}
   179  			endpoints[endpoint] = true
   180  		}
   181  	}
   182  
   183  	data, err := proto.Marshal(&defangv1.ListServicesResponse{
   184  		Services: serviceInfos,
   185  	})
   186  	if err != nil {
   187  		return nil, err
   188  	}
   189  
   190  	var payloadString string
   191  	if len(data) < 1000 {
   192  		// Small payloads can be sent as base64-encoded command-line argument
   193  		payloadString = base64.StdEncoding.EncodeToString(data)
   194  		// TODO: consider making this a proper Data URL: "data:application/protobuf;base64,abcd…"
   195  	} else {
   196  		url, err := b.driver.CreateUploadURL(ctx, etag)
   197  		if err != nil {
   198  			return nil, err
   199  		}
   200  
   201  		// Do an HTTP PUT to the generated URL
   202  		resp, err := http.Put(ctx, url, "application/protobuf", bytes.NewReader(data))
   203  		if err != nil {
   204  			return nil, err
   205  		}
   206  		defer resp.Body.Close()
   207  		if resp.StatusCode != 200 {
   208  			return nil, fmt.Errorf("unexpected status code during upload: %s", resp.Status)
   209  		}
   210  		payloadString = http.RemoveQueryParam(url)
   211  		// FIXME: this code path didn't work
   212  	}
   213  
   214  	if b.shouldDelegateSubdomain {
   215  		if _, err := b.delegateSubdomain(ctx); err != nil {
   216  			return nil, err
   217  		}
   218  	}
   219  	taskArn, err := b.runCdCommand(ctx, "up", payloadString)
   220  	if err != nil {
   221  		return nil, err
   222  	}
   223  	b.cdTasks[etag] = taskArn
   224  
   225  	for _, si := range serviceInfos {
   226  		if si.UseAcmeCert {
   227  			term.Infof("To activate let's encrypt SSL certificate for %v, run 'defang cert gen'", si.Service.Domainname)
   228  		}
   229  	}
   230  
   231  	return &defangv1.DeployResponse{
   232  		Services: serviceInfos, // TODO: Should we use the retrieved services instead?
   233  		Etag:     etag,
   234  	}, nil
   235  }
   236  
   237  func (b ByocAws) findZone(ctx context.Context, domain, role string) (string, error) {
   238  	cfg, err := b.driver.LoadConfig(ctx)
   239  	if err != nil {
   240  		return "", annotateAwsError(err)
   241  	}
   242  
   243  	if role != "" {
   244  		stsClient := sts.NewFromConfig(cfg)
   245  		creds := stscreds.NewAssumeRoleProvider(stsClient, role)
   246  		cfg.Credentials = aws2.NewCredentialsCache(creds)
   247  	}
   248  
   249  	r53Client := route53.NewFromConfig(cfg)
   250  
   251  	domain = strings.TrimSuffix(domain, ".")
   252  	domain = strings.ToLower(domain)
   253  	for {
   254  		zoneId, err := aws.GetZoneIdFromDomain(ctx, domain, r53Client)
   255  		if errors.Is(err, aws.ErrNoZoneFound) {
   256  			if strings.Count(domain, ".") <= 1 {
   257  				return "", nil
   258  			}
   259  			domain = domain[strings.Index(domain, ".")+1:]
   260  			continue
   261  		} else if err != nil {
   262  			return "", err
   263  		}
   264  		return zoneId, nil
   265  	}
   266  }
   267  
   268  func (b ByocAws) delegateSubdomain(ctx context.Context) (string, error) {
   269  	if b.customDomain == "" {
   270  		return "", errors.New("custom domain not set")
   271  	}
   272  	domain := b.customDomain
   273  	cfg, err := b.driver.LoadConfig(ctx)
   274  	if err != nil {
   275  		return "", annotateAwsError(err)
   276  	}
   277  	r53Client := route53.NewFromConfig(cfg)
   278  
   279  	zoneId, err := aws.GetZoneIdFromDomain(ctx, domain, r53Client)
   280  	if errors.Is(err, aws.ErrNoZoneFound) {
   281  		zoneId, err = aws.CreateZone(ctx, domain, r53Client)
   282  		if err != nil {
   283  			return "", annotateAwsError(err)
   284  		}
   285  	} else if err != nil {
   286  		return "", annotateAwsError(err)
   287  	}
   288  
   289  	// Get the NS records for the subdomain zone and call DelegateSubdomainZone again
   290  	nsServers, err := aws.GetRecordsValue(ctx, zoneId, domain, types2.RRTypeNs, r53Client)
   291  	if err != nil {
   292  		return "", annotateAwsError(err)
   293  	}
   294  	if len(nsServers) == 0 {
   295  		return "", errors.New("no NS records found for the subdomain zone")
   296  	}
   297  
   298  	req := &defangv1.DelegateSubdomainZoneRequest{NameServerRecords: nsServers}
   299  	resp, err := b.DelegateSubdomainZone(ctx, req)
   300  	if err != nil {
   301  		return "", err
   302  	}
   303  	return resp.Zone, nil
   304  }
   305  
   306  func (b ByocAws) WhoAmI(ctx context.Context) (*defangv1.WhoAmIResponse, error) {
   307  	if _, err := b.GrpcClient.WhoAmI(ctx); err != nil {
   308  		return nil, err
   309  	}
   310  
   311  	// Use STS to get the account ID
   312  	cfg, err := b.driver.LoadConfig(ctx)
   313  	if err != nil {
   314  		return nil, annotateAwsError(err)
   315  	}
   316  	identity, err := sts.NewFromConfig(cfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
   317  	if err != nil {
   318  		return nil, annotateAwsError(err)
   319  	}
   320  	return &defangv1.WhoAmIResponse{
   321  		Tenant:  b.tenantID,
   322  		Region:  cfg.Region,
   323  		Account: *identity.Account,
   324  	}, nil
   325  }
   326  
   327  func (ByocAws) GetVersions(context.Context) (*defangv1.Version, error) {
   328  	cdVersion := CdImage[strings.LastIndex(CdImage, ":")+1:]
   329  	return &defangv1.Version{Fabric: cdVersion}, nil
   330  }
   331  
   332  func (b ByocAws) Get(ctx context.Context, s *defangv1.ServiceID) (*defangv1.ServiceInfo, error) {
   333  	all, err := b.GetServices(ctx)
   334  	if err != nil {
   335  		return nil, err
   336  	}
   337  	for _, service := range all.Services {
   338  		if service.Service.Name == s.Name {
   339  			return service, nil
   340  		}
   341  	}
   342  	return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("service %q not found", s.Name))
   343  }
   344  
   345  func (b *ByocAws) environment() map[string]string {
   346  	region := b.driver.Region // TODO: this should be the destination region, not the CD region; make customizable
   347  	return map[string]string{
   348  		// "AWS_REGION":               region.String(), should be set by ECS (because of CD task role)
   349  		"DEFANG_PREFIX":              DefangPrefix,
   350  		"DEFANG_DEBUG":               os.Getenv("DEFANG_DEBUG"), // TODO: use the global DoDebug flag
   351  		"DEFANG_ORG":                 b.tenantID,
   352  		"DOMAIN":                     b.customDomain,
   353  		"PRIVATE_DOMAIN":             b.privateDomain,
   354  		"PROJECT":                    b.pulumiProject,
   355  		"PULUMI_BACKEND_URL":         fmt.Sprintf(`s3://%s?region=%s&awssdk=v2`, b.driver.BucketName, region), // TODO: add a way to override bucket
   356  		"PULUMI_CONFIG_PASSPHRASE":   pkg.Getenv("PULUMI_CONFIG_PASSPHRASE", "asdf"),                          // TODO: make customizable
   357  		"STACK":                      b.pulumiStack,
   358  		"NPM_CONFIG_UPDATE_NOTIFIER": "false",
   359  		"PULUMI_SKIP_UPDATE_CHECK":   "true",
   360  	}
   361  }
   362  
   363  func (b *ByocAws) runCdCommand(ctx context.Context, cmd ...string) (ecs.TaskArn, error) {
   364  	env := b.environment()
   365  	if term.DoDebug {
   366  		debugEnv := " -"
   367  		for k, v := range env {
   368  			debugEnv += " " + k + "=" + v
   369  		}
   370  		term.Debug(debugEnv, "npm run dev", strings.Join(cmd, " "))
   371  	}
   372  	return b.driver.Run(ctx, env, cmd...)
   373  }
   374  
   375  func (b *ByocAws) Delete(ctx context.Context, req *defangv1.DeleteRequest) (*defangv1.DeleteResponse, error) {
   376  	if err := b.setUp(ctx); err != nil {
   377  		return nil, err
   378  	}
   379  	// FIXME: this should only delete the services that are specified in the request, not all
   380  	taskArn, err := b.runCdCommand(ctx, "up", "")
   381  	if err != nil {
   382  		return nil, annotateAwsError(err)
   383  	}
   384  	etag := ecs.GetTaskID(taskArn) // TODO: this is the CD task ID, not the etag
   385  	b.cdTasks[etag] = taskArn
   386  	return &defangv1.DeleteResponse{Etag: etag}, nil
   387  }
   388  
   389  // stack returns a stack-qualified name, like the Pulumi TS function `stack`
   390  func (b *ByocAws) stack(name string) string {
   391  	return fmt.Sprintf("%s-%s-%s-%s", DefangPrefix, b.pulumiProject, b.pulumiStack, name) // same as shared/common.ts
   392  }
   393  
   394  func (b *ByocAws) stackDir(name string) string {
   395  	return fmt.Sprintf("/%s/%s/%s/%s", DefangPrefix, b.pulumiProject, b.pulumiStack, name) // same as shared/common.ts
   396  }
   397  
   398  func (b *ByocAws) getClusterNames() []string {
   399  	// This should match the naming in pulumi/ecs/common.ts
   400  	return []string{
   401  		b.stack("cluster"),
   402  		b.stack("gpu-cluster"),
   403  	}
   404  }
   405  
   406  func (b ByocAws) GetServices(ctx context.Context) (*defangv1.ListServicesResponse, error) {
   407  	if err := b.driver.FillOutputs(ctx); err != nil {
   408  		return nil, err
   409  	}
   410  
   411  	cfg, err := b.driver.LoadConfig(ctx)
   412  	if err != nil {
   413  		return nil, annotateAwsError(err)
   414  	}
   415  
   416  	s3Client := s3.NewFromConfig(cfg)
   417  	bucket := b.driver.BucketName
   418  	// Path to the state file, Defined at: https://github.com/defang-io/defang-mvp/blob/main/pulumi/cd/byoc/aws/index.ts#L89
   419  	path := fmt.Sprintf("projects/%s/%s/project.pb", b.pulumiProject, b.pulumiStack)
   420  
   421  	getObjectOutput, err := s3Client.GetObject(ctx, &s3.GetObjectInput{
   422  		Bucket: &bucket,
   423  		Key:    &path,
   424  	})
   425  	if err != nil {
   426  		return nil, annotateAwsError(err)
   427  	}
   428  	defer getObjectOutput.Body.Close()
   429  	pbBytes, err := io.ReadAll(getObjectOutput.Body)
   430  	if err != nil {
   431  		return nil, err
   432  	}
   433  	var serviceInfos defangv1.ListServicesResponse
   434  	if err := proto.Unmarshal(pbBytes, &serviceInfos); err != nil {
   435  		return nil, err
   436  	}
   437  	return &serviceInfos, nil
   438  }
   439  
   440  func (b ByocAws) getSecretID(name string) string {
   441  	return fmt.Sprintf("/%s/%s/%s/%s", DefangPrefix, b.pulumiProject, b.pulumiStack, name) // same as defang_service.ts
   442  }
   443  
   444  func (b ByocAws) PutConfig(ctx context.Context, secret *defangv1.SecretValue) error {
   445  	if !pkg.IsValidSecretName(secret.Name) {
   446  		return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid secret name; must be alphanumeric or _, cannot start with a number: %q", secret.Name))
   447  	}
   448  	fqn := b.getSecretID(secret.Name)
   449  	err := b.driver.PutSecret(ctx, fqn, secret.Value)
   450  	return annotateAwsError(err)
   451  }
   452  
   453  func (b ByocAws) ListConfig(ctx context.Context) (*defangv1.Secrets, error) {
   454  	prefix := b.getSecretID("")
   455  	awsSecrets, err := b.driver.ListSecretsByPrefix(ctx, prefix)
   456  	if err != nil {
   457  		return nil, err
   458  	}
   459  	configs := make([]string, len(awsSecrets))
   460  	for i, secret := range awsSecrets {
   461  		configs[i] = strings.TrimPrefix(secret, prefix)
   462  	}
   463  	return &defangv1.Secrets{Names: configs}, nil
   464  }
   465  
   466  func (b *ByocAws) CreateUploadURL(ctx context.Context, req *defangv1.UploadURLRequest) (*defangv1.UploadURLResponse, error) {
   467  	if err := b.setUp(ctx); err != nil {
   468  		return nil, err
   469  	}
   470  
   471  	url, err := b.driver.CreateUploadURL(ctx, req.Digest)
   472  	if err != nil {
   473  		return nil, err
   474  	}
   475  	return &defangv1.UploadURLResponse{
   476  		Url: url,
   477  	}, nil
   478  }
   479  
   480  func (b *ByocAws) Tail(ctx context.Context, req *defangv1.TailRequest) (client.ServerStream[defangv1.TailResponse], error) {
   481  	if err := b.setUp(ctx); err != nil {
   482  		return nil, err
   483  	}
   484  
   485  	ctx, cancel := context.WithCancelCause(ctx)
   486  
   487  	etag := req.Etag
   488  	// if etag == "" && req.Service == "cd" {
   489  	// 	etag = awsecs.GetTaskID(b.cdTaskArn); TODO: find the last CD task
   490  	// }
   491  	// How to tail multiple tasks/services at once?
   492  	//  * No Etag, no service:	tail all tasks/services
   493  	//  * Etag, no service: 	tail all tasks/services with that Etag
   494  	//  * No Etag, service:		tail all tasks/services with that service name
   495  	//  * Etag, service:		tail that task/service
   496  	var err error
   497  	var taskArn ecs.TaskArn
   498  	var eventStream ecs.EventStream
   499  	if etag != "" && !pkg.IsValidRandomID(etag) {
   500  		// Assume "etag" is a task ID
   501  		eventStream, err = b.driver.TailTaskID(ctx, etag)
   502  		taskArn, _ = b.driver.GetTaskArn(etag)
   503  		etag = "" // no need to filter by etag
   504  	} else {
   505  		// Tail CD, kaniko, and all services
   506  		kanikoTail := ecs.LogGroupInput{LogGroupARN: b.driver.MakeARN("logs", "log-group:"+b.stackDir("builds"))} // must match logic in ecs/common.ts
   507  		servicesTail := ecs.LogGroupInput{LogGroupARN: b.driver.MakeARN("logs", "log-group:"+b.stackDir("logs"))} // must match logic in ecs/common.ts
   508  		cdTail := ecs.LogGroupInput{LogGroupARN: b.driver.LogGroupARN}
   509  		taskArn = b.cdTasks[etag]
   510  		if taskArn != nil {
   511  			// Only tail the logstreams for the CD task
   512  			cdTail.LogStreamNames = []string{ecs.GetLogStreamForTaskID(ecs.GetTaskID(taskArn))}
   513  		}
   514  		eventStream, err = ecs.TailLogGroups(ctx, req.Since.AsTime(), cdTail, kanikoTail, servicesTail)
   515  	}
   516  	if err != nil {
   517  		return nil, annotateAwsError(err)
   518  	}
   519  
   520  	if taskArn != nil {
   521  		go func() {
   522  			if err := ecs.WaitForTask(ctx, taskArn, 3*time.Second); err != nil {
   523  				time.Sleep(time.Second) // make sure we got all the logs from the task before cancelling
   524  				cancel(err)
   525  			}
   526  		}()
   527  	}
   528  
   529  	return newByocServerStream(ctx, eventStream, etag, req.Service), nil
   530  }
   531  
   532  // This function was copied from Fabric controller and slightly modified to work with BYOC
   533  func (b ByocAws) update(ctx context.Context, service *defangv1.Service) (*defangv1.ServiceInfo, error) {
   534  	if err := b.quota.Validate(service); err != nil {
   535  		return nil, err
   536  	}
   537  
   538  	// Check to make sure all required secrets are present in the secrets store
   539  	missing, err := b.checkForMissingSecrets(ctx, service.Secrets)
   540  	if err != nil {
   541  		return nil, err
   542  	}
   543  	if missing != nil {
   544  		return nil, fmt.Errorf("missing config %s", missing) // retryable CodeFailedPrecondition
   545  	}
   546  
   547  	si := &defangv1.ServiceInfo{
   548  		Service: service,
   549  		Project: b.pulumiProject, // was: tenant
   550  		Etag:    pkg.RandomID(),  // TODO: could be hash for dedup/idempotency
   551  	}
   552  
   553  	hasHost := false
   554  	hasIngress := false
   555  	fqn := service.Name
   556  	if service.StaticFiles == "" {
   557  		for _, port := range service.Ports {
   558  			hasIngress = hasIngress || port.Mode == defangv1.Mode_INGRESS
   559  			hasHost = hasHost || port.Mode == defangv1.Mode_HOST
   560  			si.Endpoints = append(si.Endpoints, b.getEndpoint(fqn, port))
   561  		}
   562  	} else {
   563  		si.PublicFqdn = b.getPublicFqdn(fqn)
   564  		si.Endpoints = append(si.Endpoints, si.PublicFqdn)
   565  	}
   566  	if hasIngress {
   567  		si.LbIps = b.privateLbIps // only set LB IPs if there are ingress ports
   568  		si.PublicFqdn = b.getPublicFqdn(fqn)
   569  	}
   570  	if hasHost {
   571  		si.PrivateFqdn = b.getPrivateFqdn(fqn)
   572  	}
   573  
   574  	if service.Domainname != "" {
   575  		if !hasIngress && service.StaticFiles == "" {
   576  			return nil, errors.New("domainname requires at least one ingress port") // retryable CodeFailedPrecondition
   577  		}
   578  		// Do a DNS lookup for Domainname and confirm it's indeed a CNAME to the service's public FQDN
   579  		cname, _ := net.LookupCNAME(service.Domainname)
   580  		if strings.TrimSuffix(cname, ".") != si.PublicFqdn {
   581  			zoneId, err := b.findZone(ctx, service.Domainname, service.DnsRole)
   582  			if err != nil {
   583  				return nil, err
   584  			}
   585  			if zoneId == "" {
   586  				si.UseAcmeCert = true
   587  				// TODO: We should add link to documentation on how the acme cert workflow works
   588  				// TODO: Should we make this the default behavior or require the user to set a flag?
   589  			} else {
   590  				si.ZoneId = zoneId
   591  			}
   592  		}
   593  	}
   594  
   595  	si.NatIps = b.publicNatIps // TODO: even internal services use NAT now
   596  	si.Status = "UPDATE_QUEUED"
   597  	if si.Service.Build != nil {
   598  		si.Status = "BUILD_QUEUED" // in SaaS, this gets overwritten by the ECS events for "kaniko"
   599  	}
   600  	return si, nil
   601  }
   602  
   603  // This function was copied from Fabric controller and slightly modified to work with BYOC
   604  func (b ByocAws) checkForMissingSecrets(ctx context.Context, secrets []*defangv1.Secret) (*defangv1.Secret, error) {
   605  	if len(secrets) == 0 {
   606  		return nil, nil // no secrets to check
   607  	}
   608  	prefix := b.getSecretID("")
   609  	sorted, err := b.driver.ListSecretsByPrefix(ctx, prefix)
   610  	if err != nil {
   611  		return nil, err
   612  	}
   613  	for _, secret := range secrets {
   614  		fqn := b.getSecretID(secret.Source)
   615  		if !searchSecret(sorted, fqn) {
   616  			return secret, nil // secret not found
   617  		}
   618  	}
   619  	return nil, nil // all secrets found
   620  }
   621  
   622  // This function was copied from Fabric controller
   623  func searchSecret(sorted []qualifiedName, fqn qualifiedName) bool {
   624  	i := sort.Search(len(sorted), func(i int) bool {
   625  		return sorted[i] >= fqn
   626  	})
   627  	return i < len(sorted) && sorted[i] == fqn
   628  }
   629  
   630  type qualifiedName = string // legacy
   631  
   632  // This function was copied from Fabric controller and slightly modified to work with BYOC
   633  func (b ByocAws) getEndpoint(fqn qualifiedName, port *defangv1.Port) string {
   634  	if port.Mode == defangv1.Mode_HOST {
   635  		privateFqdn := b.getPrivateFqdn(fqn)
   636  		return fmt.Sprintf("%s:%d", privateFqdn, port.Target)
   637  	}
   638  	if b.customDomain == "" {
   639  		return ":443" // placeholder for the public ALB/distribution
   640  	}
   641  	safeFqn := dnsSafeLabel(fqn)
   642  	return fmt.Sprintf("%s--%d.%s", safeFqn, port.Target, b.customDomain)
   643  
   644  }
   645  
   646  // This function was copied from Fabric controller and slightly modified to work with BYOC
   647  func (b ByocAws) getPublicFqdn(fqn qualifiedName) string {
   648  	if b.customDomain == "" {
   649  		return "" //b.fqdn
   650  	}
   651  	safeFqn := dnsSafeLabel(fqn)
   652  	return fmt.Sprintf("%s.%s", safeFqn, b.customDomain)
   653  }
   654  
   655  // This function was copied from Fabric controller and slightly modified to work with BYOC
   656  func (b ByocAws) getPrivateFqdn(fqn qualifiedName) string {
   657  	safeFqn := dnsSafeLabel(fqn)
   658  	return fmt.Sprintf("%s.%s", safeFqn, b.privateDomain) // TODO: consider merging this with ServiceDNS
   659  }
   660  
   661  func (b ByocAws) getProjectDomain(zone string) string {
   662  	projectLabel := dnsSafeLabel(b.pulumiProject)
   663  	if projectLabel == dnsSafeLabel(b.tenantID) {
   664  		return dnsSafe(zone) // the zone will already have the tenant ID
   665  	}
   666  	return projectLabel + "." + dnsSafe(zone)
   667  }
   668  
   669  // This function was copied from Fabric controller and slightly modified to work with BYOC
   670  func dnsSafeLabel(fqn qualifiedName) string {
   671  	return strings.ReplaceAll(dnsSafe(fqn), ".", "-")
   672  }
   673  
   674  func dnsSafe(fqdn string) string {
   675  	return strings.ToLower(fqdn)
   676  }
   677  
   678  func (b *ByocAws) TearDown(ctx context.Context) error {
   679  	return b.driver.TearDown(ctx)
   680  }
   681  
   682  func (b *ByocAws) BootstrapCommand(ctx context.Context, command string) (string, error) {
   683  	if err := b.setUp(ctx); err != nil {
   684  		return "", err
   685  	}
   686  	cdTaskArn, err := b.runCdCommand(ctx, command)
   687  	if err != nil || cdTaskArn == nil {
   688  		return "", annotateAwsError(err)
   689  	}
   690  	return ecs.GetTaskID(cdTaskArn), nil
   691  }
   692  
   693  func (b *ByocAws) Destroy(ctx context.Context) (string, error) {
   694  	return b.BootstrapCommand(ctx, "down")
   695  }
   696  
   697  func (b *ByocAws) DeleteConfig(ctx context.Context, secrets *defangv1.Secrets) error {
   698  	ids := make([]string, len(secrets.Names))
   699  	for i, name := range secrets.Names {
   700  		ids[i] = b.getSecretID(name)
   701  	}
   702  	if err := b.driver.DeleteSecrets(ctx, ids...); err != nil {
   703  		return annotateAwsError(err)
   704  	}
   705  	return nil
   706  }
   707  
   708  func (b *ByocAws) Restart(ctx context.Context, names ...string) (client.ETag, error) {
   709  	return "", errors.New("not yet implemented for BYOC; please use the AWS ECS dashboard") // FIXME: implement this for BYOC
   710  }
   711  
   712  func (b *ByocAws) BootstrapList(ctx context.Context) error {
   713  	if err := b.setUp(ctx); err != nil {
   714  		return err
   715  	}
   716  	cfg, err := b.driver.LoadConfig(ctx)
   717  	if err != nil {
   718  		return annotateAwsError(err)
   719  	}
   720  	prefix := `.pulumi/stacks/` // TODO: should we filter on `projectName`?
   721  	s3client := s3.NewFromConfig(cfg)
   722  	out, err := s3client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
   723  		Bucket: &b.driver.BucketName,
   724  		Prefix: &prefix,
   725  	})
   726  	if err != nil {
   727  		return annotateAwsError(err)
   728  	}
   729  	for _, obj := range out.Contents {
   730  		// The JSON file for an empty stack is ~600 bytes; we add a margin of 100 bytes to account for the length of the stack/project names
   731  		if obj.Key == nil || !strings.HasSuffix(*obj.Key, ".json") || obj.Size == nil || *obj.Size < 700 {
   732  			continue
   733  		}
   734  		// Cut off the prefix and the .json suffix
   735  		stack := (*obj.Key)[len(prefix) : len(*obj.Key)-5]
   736  		fmt.Println(" - ", stack)
   737  	}
   738  	return nil
   739  }
   740  
   741  func getQualifiedNameFromEcsName(ecsService string) qualifiedName {
   742  	// HACK: Pulumi adds a random 8-char suffix to the service name, so we need to strip it off.
   743  	if len(ecsService) < 10 || ecsService[len(ecsService)-8] != '-' {
   744  		return ""
   745  	}
   746  	serviceName := ecsService[:len(ecsService)-8]
   747  
   748  	// Replace the first underscore to get the FQN.
   749  	return qualifiedName(strings.Replace(serviceName, "_", ".", 1))
   750  }
   751  
   752  // annotateAwsError translates the AWS error to an error code the CLI client understands
   753  func annotateAwsError(err error) error {
   754  	if err == nil {
   755  		return nil
   756  	}
   757  	if strings.Contains(err.Error(), "get credentials:") {
   758  		return connect.NewError(connect.CodeUnauthenticated, err)
   759  	}
   760  	if aws.IsS3NoSuchKeyError(err) {
   761  		return connect.NewError(connect.CodeNotFound, err)
   762  	}
   763  	if aws.IsParameterNotFoundError(err) {
   764  		return connect.NewError(connect.CodeNotFound, err)
   765  	}
   766  	return err
   767  }
   768  
   769  func (b *ByocAws) ServiceDNS(name string) string {
   770  	return dnsSafeLabel(name) // TODO: consider merging this with getPrivateFqdn
   771  }