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 }