From 686d9afab32f19c9cbb36fce9fc4bd3d59238364 Mon Sep 17 00:00:00 2001 From: Lionello Lunesu <lio+git@lunesu.com> Date: Fri, 13 Dec 2024 06:47:03 -0800 Subject: [PATCH 1/3] Handle multiple deployments --- src/pkg/cli/client/byoc/aws/byoc.go | 12 +- src/pkg/cli/client/byoc/aws/byoc_test.go | 10 +- .../build-failure-o4epidtq3j3b.json | 0 .../build-failure.events | 0 .../failure-then-success-c1v6g2m5qlvm.json | 0 .../failure-then-success.events | 0 .../healthcheck-failure-8ui3h0yf5xqg.json | 0 .../healthcheck-failure.events | 0 .../processexit-failure-se3n0qmzhzpm.json | 0 .../processexit-failure.events | 0 .../success-f249u7ap07ef.json | 0 .../success.events | 0 src/pkg/cli/client/byoc/baseclient.go | 48 ----- src/pkg/cli/client/byoc/do/byoc.go | 185 ++++++++++++++---- src/pkg/cli/client/byoc/gcp/byoc.go | 2 +- src/pkg/cli/client/byoc/parse.go | 57 ++++++ src/pkg/clouds/do/appPlatform/setup.go | 2 +- 17 files changed, 214 insertions(+), 102 deletions(-) rename src/pkg/cli/client/byoc/aws/{test_ecs_events => testdata}/build-failure-o4epidtq3j3b.json (100%) rename src/pkg/cli/client/byoc/aws/{test_ecs_events => testdata}/build-failure.events (100%) rename src/pkg/cli/client/byoc/aws/{test_ecs_events => testdata}/failure-then-success-c1v6g2m5qlvm.json (100%) rename src/pkg/cli/client/byoc/aws/{test_ecs_events => testdata}/failure-then-success.events (100%) rename src/pkg/cli/client/byoc/aws/{test_ecs_events => testdata}/healthcheck-failure-8ui3h0yf5xqg.json (100%) rename src/pkg/cli/client/byoc/aws/{test_ecs_events => testdata}/healthcheck-failure.events (100%) rename src/pkg/cli/client/byoc/aws/{test_ecs_events => testdata}/processexit-failure-se3n0qmzhzpm.json (100%) rename src/pkg/cli/client/byoc/aws/{test_ecs_events => testdata}/processexit-failure.events (100%) rename src/pkg/cli/client/byoc/aws/{test_ecs_events => testdata}/success-f249u7ap07ef.json (100%) rename src/pkg/cli/client/byoc/aws/{test_ecs_events => testdata}/success.events (100%) create mode 100644 src/pkg/cli/client/byoc/parse.go diff --git a/src/pkg/cli/client/byoc/aws/byoc.go b/src/pkg/cli/client/byoc/aws/byoc.go index 3bf84620a..a6acac8f3 100644 --- a/src/pkg/cli/client/byoc/aws/byoc.go +++ b/src/pkg/cli/client/byoc/aws/byoc.go @@ -930,13 +930,13 @@ func (b *ByocAws) DeleteConfig(ctx context.Context, secrets *defangv1.Secrets) e return nil } -type awsObj struct{ obj s3types.Object } +type s3Obj struct{ obj s3types.Object } -func (a awsObj) Name() string { +func (a s3Obj) Name() string { return *a.obj.Key } -func (a awsObj) Size() int64 { +func (a s3Obj) Size() int64 { return *a.obj.Size } @@ -955,6 +955,10 @@ func (b *ByocAws) BootstrapList(ctx context.Context) ([]string, error) { } s3client := s3.NewFromConfig(cfg) + return ListPulumiStacks(ctx, s3client, bucketName) +} + +func ListPulumiStacks(ctx context.Context, s3client *s3.Client, bucketName string) ([]string, error) { prefix := `.pulumi/stacks/` // TODO: should we filter on `projectName`? term.Debug("Listing stacks in bucket:", bucketName) @@ -970,7 +974,7 @@ func (b *ByocAws) BootstrapList(ctx context.Context) ([]string, error) { if obj.Key == nil || obj.Size == nil { continue } - stack, err := b.ParsePulumiStackObject(ctx, awsObj{obj}, bucketName, prefix, func(ctx context.Context, bucket, path string) ([]byte, error) { + stack, err := byoc.ParsePulumiStackObject(ctx, s3Obj{obj}, bucketName, prefix, func(ctx context.Context, bucket, path string) ([]byte, error) { getObjectOutput, err := s3client.GetObject(ctx, &s3.GetObjectInput{ Bucket: &bucket, Key: &path, diff --git a/src/pkg/cli/client/byoc/aws/byoc_test.go b/src/pkg/cli/client/byoc/aws/byoc_test.go index 80ecaf6be..429b4597a 100644 --- a/src/pkg/cli/client/byoc/aws/byoc_test.go +++ b/src/pkg/cli/client/byoc/aws/byoc_test.go @@ -88,15 +88,15 @@ func (f FakeLoader) LoadProjectName(ctx context.Context) (string, error) { return f.ProjectName, nil } -//go:embed test_ecs_events/*.json +//go:embed testdata/*.json var testDir embed.FS -//go:embed test_ecs_events/*.events +//go:embed testdata/*.events var expectedDir embed.FS func TestSubscribe(t *testing.T) { t.Skip("Pending test") - tests, err := testDir.ReadDir("test_ecs_events") + tests, err := testDir.ReadDir("testdata") if err != nil { t.Fatalf("failed to load ecs events test files: %v", err) } @@ -125,7 +125,7 @@ func TestSubscribe(t *testing.T) { go func() { defer wg.Done() - filename := path.Join("test_ecs_events", name+".events") + filename := path.Join("testdata", name+".events") ef, _ := expectedDir.ReadFile(filename) dec := json.NewDecoder(bytes.NewReader(ef)) @@ -148,7 +148,7 @@ func TestSubscribe(t *testing.T) { } }() - data, err := testDir.ReadFile(path.Join("test_ecs_events", tt.Name())) + data, err := testDir.ReadFile(path.Join("testdata", tt.Name())) if err != nil { t.Fatalf("failed to read test file: %v", err) } diff --git a/src/pkg/cli/client/byoc/aws/test_ecs_events/build-failure-o4epidtq3j3b.json b/src/pkg/cli/client/byoc/aws/testdata/build-failure-o4epidtq3j3b.json similarity index 100% rename from src/pkg/cli/client/byoc/aws/test_ecs_events/build-failure-o4epidtq3j3b.json rename to src/pkg/cli/client/byoc/aws/testdata/build-failure-o4epidtq3j3b.json diff --git a/src/pkg/cli/client/byoc/aws/test_ecs_events/build-failure.events b/src/pkg/cli/client/byoc/aws/testdata/build-failure.events similarity index 100% rename from src/pkg/cli/client/byoc/aws/test_ecs_events/build-failure.events rename to src/pkg/cli/client/byoc/aws/testdata/build-failure.events diff --git a/src/pkg/cli/client/byoc/aws/test_ecs_events/failure-then-success-c1v6g2m5qlvm.json b/src/pkg/cli/client/byoc/aws/testdata/failure-then-success-c1v6g2m5qlvm.json similarity index 100% rename from src/pkg/cli/client/byoc/aws/test_ecs_events/failure-then-success-c1v6g2m5qlvm.json rename to src/pkg/cli/client/byoc/aws/testdata/failure-then-success-c1v6g2m5qlvm.json diff --git a/src/pkg/cli/client/byoc/aws/test_ecs_events/failure-then-success.events b/src/pkg/cli/client/byoc/aws/testdata/failure-then-success.events similarity index 100% rename from src/pkg/cli/client/byoc/aws/test_ecs_events/failure-then-success.events rename to src/pkg/cli/client/byoc/aws/testdata/failure-then-success.events diff --git a/src/pkg/cli/client/byoc/aws/test_ecs_events/healthcheck-failure-8ui3h0yf5xqg.json b/src/pkg/cli/client/byoc/aws/testdata/healthcheck-failure-8ui3h0yf5xqg.json similarity index 100% rename from src/pkg/cli/client/byoc/aws/test_ecs_events/healthcheck-failure-8ui3h0yf5xqg.json rename to src/pkg/cli/client/byoc/aws/testdata/healthcheck-failure-8ui3h0yf5xqg.json diff --git a/src/pkg/cli/client/byoc/aws/test_ecs_events/healthcheck-failure.events b/src/pkg/cli/client/byoc/aws/testdata/healthcheck-failure.events similarity index 100% rename from src/pkg/cli/client/byoc/aws/test_ecs_events/healthcheck-failure.events rename to src/pkg/cli/client/byoc/aws/testdata/healthcheck-failure.events diff --git a/src/pkg/cli/client/byoc/aws/test_ecs_events/processexit-failure-se3n0qmzhzpm.json b/src/pkg/cli/client/byoc/aws/testdata/processexit-failure-se3n0qmzhzpm.json similarity index 100% rename from src/pkg/cli/client/byoc/aws/test_ecs_events/processexit-failure-se3n0qmzhzpm.json rename to src/pkg/cli/client/byoc/aws/testdata/processexit-failure-se3n0qmzhzpm.json diff --git a/src/pkg/cli/client/byoc/aws/test_ecs_events/processexit-failure.events b/src/pkg/cli/client/byoc/aws/testdata/processexit-failure.events similarity index 100% rename from src/pkg/cli/client/byoc/aws/test_ecs_events/processexit-failure.events rename to src/pkg/cli/client/byoc/aws/testdata/processexit-failure.events diff --git a/src/pkg/cli/client/byoc/aws/test_ecs_events/success-f249u7ap07ef.json b/src/pkg/cli/client/byoc/aws/testdata/success-f249u7ap07ef.json similarity index 100% rename from src/pkg/cli/client/byoc/aws/test_ecs_events/success-f249u7ap07ef.json rename to src/pkg/cli/client/byoc/aws/testdata/success-f249u7ap07ef.json diff --git a/src/pkg/cli/client/byoc/aws/test_ecs_events/success.events b/src/pkg/cli/client/byoc/aws/testdata/success.events similarity index 100% rename from src/pkg/cli/client/byoc/aws/test_ecs_events/success.events rename to src/pkg/cli/client/byoc/aws/testdata/success.events diff --git a/src/pkg/cli/client/byoc/baseclient.go b/src/pkg/cli/client/byoc/baseclient.go index 5d6d8d8a3..5c4f694fa 100644 --- a/src/pkg/cli/client/byoc/baseclient.go +++ b/src/pkg/cli/client/byoc/baseclient.go @@ -2,7 +2,6 @@ package byoc import ( "context" - "encoding/json" "errors" "fmt" "os" @@ -166,50 +165,3 @@ func (b *ByocBaseClient) GetProjectDomain(projectName, zone string) string { func GetPrivateDomain(projectName string) string { return DnsSafeLabel(projectName) + ".internal" } - -type Obj interface { - Name() string - Size() int64 -} - -func (b *ByocBaseClient) ParsePulumiStackObject(ctx context.Context, obj Obj, bucket, prefix string, objLoader func(ctx context.Context, bucket, object string) ([]byte, error)) (string, error) { - // 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 - if !strings.HasSuffix(obj.Name(), ".json") || obj.Size() < 700 { - return "", nil - } - // Cut off the prefix and the .json suffix - stack := (obj.Name())[len(prefix) : len(obj.Name())-5] - // Check the contents of the JSON file, because the size is not a reliable indicator of a valid stack - data, err := objLoader(ctx, bucket, obj.Name()) - if err != nil { - return "", fmt.Errorf("failed to get Pulumi state object %q: %w", obj.Name(), err) - } - var state struct { - Version int `json:"version"` - Checkpoint struct { - // Stack string `json:"stack"` TODO: could use this instead of deriving the stack name from the key - Latest struct { - Resources []struct{} `json:"resources,omitempty"` - PendingOperations []struct { - Resource struct { - Urn string `json:"urn"` - } - } `json:"pending_operations,omitempty"` - } - } - } - if err := json.Unmarshal(data, &state); err != nil { - return "", fmt.Errorf("Failed to decode Pulumi state %q: %w", obj.Name(), err) - } else if state.Version != 3 { - term.Debug("Skipping Pulumi state with version", state.Version) - } else if len(state.Checkpoint.Latest.PendingOperations) > 0 { - for _, op := range state.Checkpoint.Latest.PendingOperations { - parts := strings.Split(op.Resource.Urn, "::") // prefix::project::type::resource => urn:provider:stack::project::plugin:file:class::name - stack += fmt.Sprintf(" (pending %q)", parts[3]) - } - } else if len(state.Checkpoint.Latest.Resources) == 0 { - return "", nil // skip: no resources and no pending operations - } - - return stack, nil -} diff --git a/src/pkg/cli/client/byoc/do/byoc.go b/src/pkg/cli/client/byoc/do/byoc.go index 2f222f553..9b680e4ce 100644 --- a/src/pkg/cli/client/byoc/do/byoc.go +++ b/src/pkg/cli/client/byoc/do/byoc.go @@ -52,9 +52,13 @@ var ( type ByocDo struct { *byoc.ByocBaseClient - buildRepo string - client *godo.Client - driver *appPlatform.DoApp + buildRepo string + client *godo.Client + driver *appPlatform.DoApp + lastCdAppID string + lastCdDeploymentID string + lastCdEtag types.ETag + // lastCdStart time.Time } var _ client.Provider = (*ByocDo)(nil) @@ -76,12 +80,12 @@ func NewByocProvider(ctx context.Context, tenantName types.TenantName) *ByocDo { } func (b *ByocDo) GetProjectUpdate(ctx context.Context, projectName string) (*defangv1.ProjectUpdate, error) { - client, err := b.driver.CreateS3Client() + s3client, err := b.driver.CreateS3Client() if err != nil { return nil, err } - bucketName, err := b.driver.GetBucketName(ctx, client) + bucketName, err := b.driver.GetBucketName(ctx, s3client) if err != nil { return nil, err } @@ -92,7 +96,7 @@ func (b *ByocDo) GetProjectUpdate(ctx context.Context, projectName string) (*def } path := fmt.Sprintf("projects/%s/%s/project.pb", projectName, b.PulumiStack) - getObjectOutput, err := client.GetObject(ctx, &s3.GetObjectInput{ + getObjectOutput, err := s3client.GetObject(ctx, &s3.GetObjectInput{ Bucket: &bucketName, Key: &path, }) @@ -204,6 +208,7 @@ func (b *ByocDo) deploy(ctx context.Context, req *defangv1.DeployRequest, cmd st return nil, err } + b.lastCdEtag = etag return &defangv1.DeployResponse{ Services: serviceInfos, Etag: etag, @@ -219,28 +224,24 @@ func (b *ByocDo) BootstrapCommand(ctx context.Context, req client.BootstrapComma if err != nil { return "", err } - etag := pkg.RandomID() + etag := pkg.RandomID() + b.lastCdEtag = etag return etag, nil } func (b *ByocDo) BootstrapList(ctx context.Context) ([]string, error) { - // Use DO api to query which apps (or projects) exist based on defang constant - - var projectList []string - - projects, _, err := b.client.Projects.List(ctx, &godo.ListOptions{}) + s3client, err := b.driver.CreateS3Client() if err != nil { return nil, err } - for _, project := range projects { - if strings.Contains(project.Name, "Defang") { - projectList = append(projectList, project.Name) - } + bucketName, err := b.driver.GetBucketName(ctx, s3client) + if bucketName == "" { + return nil, err } - return projectList, nil + return awsbyoc.ListPulumiStacks(ctx, s3client, bucketName) } func (b *ByocDo) CreateUploadURL(ctx context.Context, req *defangv1.UploadURLRequest) (*defangv1.UploadURLResponse, error) { @@ -299,7 +300,7 @@ func (b *ByocDo) GetService(ctx context.Context, s *defangv1.GetRequest) (*defan for _, service := range app.Spec.Services { if service.Name == s.Name { - serviceInfo = b.processServiceInfo(service, s.Project) + serviceInfo = processServiceInfo(service, s.Project) } } @@ -314,7 +315,7 @@ func (b *ByocDo) getProjectInfo(ctx context.Context, services *[]*defangv1.Servi } for _, service := range app.Spec.Services { - serviceInfo := b.processServiceInfo(service, projectName) + serviceInfo := processServiceInfo(service, projectName) *services = append(*services, serviceInfo) } @@ -373,19 +374,30 @@ func (b *ByocDo) PutConfig(ctx context.Context, config *defangv1.PutConfigReques } func (b *ByocDo) Follow(ctx context.Context, req *defangv1.TailRequest) (client.ServerStream[defangv1.TailResponse], error) { - //Look up the CD app directly instead of relying on the etag - cdApp, err := b.getAppByName(ctx, appPlatform.CdName) - if err != nil { - return nil, err - } + var appID, deploymentID string - var deploymentID string - if cdApp.PendingDeployment != nil { - deploymentID = cdApp.PendingDeployment.GetID() + if req.Etag != "" && req.Etag == b.lastCdEtag { + // Use the last known app and deployment ID from the last CD command + appID = b.lastCdAppID + deploymentID = b.lastCdDeploymentID } - if deploymentID == "" && cdApp.ActiveDeployment != nil { - deploymentID = cdApp.ActiveDeployment.GetID() + if deploymentID == "" || appID == "" { + //Look up the CD app directly instead of relying on the etag + term.Debug("Fetching app and deployment ID for app", appPlatform.CdName) + cdApp, err := b.getAppByName(ctx, appPlatform.CdName) + if err != nil { + return nil, err + } + appID = cdApp.ID + switch { + case cdApp.PendingDeployment != nil: + deploymentID = cdApp.PendingDeployment.ID + case cdApp.InProgressDeployment != nil: + deploymentID = cdApp.InProgressDeployment.ID + case cdApp.ActiveDeployment != nil: + deploymentID = cdApp.ActiveDeployment.ID + } } if deploymentID == "" { @@ -394,7 +406,7 @@ func (b *ByocDo) Follow(ctx context.Context, req *defangv1.TailRequest) (client. term.Info("Waiting for CD command to finish gathering logs") for { - deploymentInfo, _, err := b.client.Apps.GetDeployment(ctx, cdApp.ID, deploymentID) + deploymentInfo, _, err := b.client.Apps.GetDeployment(ctx, appID, deploymentID) if err != nil { return nil, err } @@ -408,7 +420,7 @@ func (b *ByocDo) Follow(ctx context.Context, req *defangv1.TailRequest) (client. case godo.DeploymentPhase_Error, godo.DeploymentPhase_Canceled: if logType.Has(logs.LogTypeBuild) { - logs, _, err := b.client.Apps.GetLogs(ctx, cdApp.ID, deploymentID, "", godo.AppLogTypeDeploy, true, 50) + logs, _, err := b.client.Apps.GetLogs(ctx, appID, deploymentID, "", godo.AppLogTypeDeploy, true, 50) if err != nil { return nil, err } @@ -418,7 +430,7 @@ func (b *ByocDo) Follow(ctx context.Context, req *defangv1.TailRequest) (client. case godo.DeploymentPhase_Active: if logType.Has(logs.LogTypeBuild) { - logs, _, err := b.client.Apps.GetLogs(ctx, cdApp.ID, deploymentID, "", godo.AppLogTypeDeploy, true, 50) + logs, _, err := b.client.Apps.GetLogs(ctx, appID, deploymentID, "", godo.AppLogTypeDeploy, true, 50) if err != nil { return nil, err } @@ -446,12 +458,12 @@ func (b *ByocDo) TearDown(ctx context.Context) error { return err } - _, err = b.client.Registry.Delete(ctx) + _, err = b.client.Apps.Delete(ctx, app.ID) if err != nil { return err } - _, err = b.client.Apps.Delete(ctx, app.ID) + _, err = b.client.Registry.Delete(ctx) if err != nil { return err } @@ -501,9 +513,92 @@ func (i DoAccountInfo) Details() string { return "" } -func (b *ByocDo) Subscribe(context.Context, *defangv1.SubscribeRequest) (client.ServerStream[defangv1.SubscribeResponse], error) { - //optional - return nil, errors.New("please check the Activity tab in the DigitalOcean App Platform console") +func (b *ByocDo) Subscribe(ctx context.Context, req *defangv1.SubscribeRequest) (client.ServerStream[defangv1.SubscribeResponse], error) { + if req.Etag != b.lastCdEtag || b.lastCdAppID == "" { + return nil, errors.ErrUnsupported // TODO: fetch the deployment ID for the given etag + } + ctx, cancel := context.WithCancel(ctx) // canceled by subscribeStream.Close() + return &subscribeStream{ + appID: b.lastCdAppID, + b: b, + deploymentID: b.lastCdDeploymentID, + ctx: ctx, + cancel: cancel, + queue: make(chan *defangv1.SubscribeResponse, 10), + }, nil +} + +type subscribeStream struct { + appID string + b *ByocDo + ctx context.Context + cancel context.CancelFunc + deploymentID string + err error + queue chan *defangv1.SubscribeResponse + msg *defangv1.SubscribeResponse +} + +func phaseToState(phase godo.DeploymentPhase) defangv1.ServiceState { + switch phase { + case godo.DeploymentPhase_Building: + return defangv1.ServiceState_BUILD_RUNNING + case godo.DeploymentPhase_Active: + return defangv1.ServiceState_DEPLOYMENT_COMPLETED + case godo.DeploymentPhase_Canceled: + return defangv1.ServiceState_DEPLOYMENT_SCALED_IN + case godo.DeploymentPhase_Error: + return defangv1.ServiceState_DEPLOYMENT_FAILED + case godo.DeploymentPhase_PendingBuild: + return defangv1.ServiceState_BUILD_QUEUED + case godo.DeploymentPhase_PendingDeploy: + return defangv1.ServiceState_UPDATE_QUEUED + case godo.DeploymentPhase_Deploying: + return defangv1.ServiceState_DEPLOYMENT_PENDING + default: + return defangv1.ServiceState_NOT_SPECIFIED + } +} + +func (s *subscribeStream) Receive() bool { + select { + case <-s.ctx.Done(): + s.err = s.ctx.Err() + s.msg = nil + return false + case r := <-s.queue: + s.msg = r + return true + default: + } + deployment, _, err := s.b.client.Apps.GetDeployment(s.ctx, s.appID, s.deploymentID) + if err != nil { + s.msg = nil + s.err = err + return false + } + for _, service := range deployment.Spec.Services { + s.queue <- &defangv1.SubscribeResponse{ + Name: service.Name, + Status: string(deployment.Phase), + State: phaseToState(deployment.Phase), + } + } + s.msg = <-s.queue + return err == nil +} + +func (s *subscribeStream) Msg() *defangv1.SubscribeResponse { + return s.msg +} + +func (s *subscribeStream) Err() error { + return s.err +} + +func (s *subscribeStream) Close() error { + s.cancel() + return nil } func (b *ByocDo) Query(ctx context.Context, req *defangv1.DebugRequest) error { @@ -527,7 +622,13 @@ func (b *ByocDo) runCdCommand(ctx context.Context, projectName, delegateDomain s } } app, err := b.driver.Run(ctx, env, b.CDImage, append([]string{"node", "lib/index.js"}, cmd...)...) - return app, err + if err != nil { + return nil, err + } + + b.lastCdAppID = app.ID + b.lastCdDeploymentID = app.PendingDeployment.ID + return app, nil } func (b *ByocDo) environment(projectName, delegateDomain string) []*godo.AppVariableDefinition { @@ -652,7 +753,7 @@ func (b *ByocDo) setUp(ctx context.Context) error { return nil } - if err := b.driver.SetUp(ctx); err != nil { + if err := b.driver.SetUpBucket(ctx); err != nil { return err } @@ -701,14 +802,12 @@ func (b *ByocDo) getAppByName(ctx context.Context, name string) (*godo.App, erro return nil, fmt.Errorf("app not found: %s", appName) } -func (b *ByocDo) processServiceInfo(service *godo.AppServiceSpec, projectName string) *defangv1.ServiceInfo { +func processServiceInfo(service *godo.AppServiceSpec, projectName string) *defangv1.ServiceInfo { serviceInfo := &defangv1.ServiceInfo{ Project: projectName, - Etag: pkg.RandomID(), + Etag: pkg.RandomID(), // TODO: get the real etag form spec somehow Service: &defangv1.Service{ Name: service.Name, - // Image: service.Image.Digest, - // Environment: getServiceEnv(service.Envs), }, } diff --git a/src/pkg/cli/client/byoc/gcp/byoc.go b/src/pkg/cli/client/byoc/gcp/byoc.go index 6466ad8b7..a9489d29d 100644 --- a/src/pkg/cli/client/byoc/gcp/byoc.go +++ b/src/pkg/cli/client/byoc/gcp/byoc.go @@ -229,7 +229,7 @@ func (b *ByocGcp) BootstrapList(ctx context.Context) ([]string, error) { var stacks []string err = b.driver.IterateBucketObjects(ctx, bucketName, prefix, func(obj *storage.ObjectAttrs) error { - stack, err := b.ParsePulumiStackObject(ctx, gcpObj{obj}, bucketName, prefix, b.driver.GetBucketObject) + stack, err := byoc.ParsePulumiStackObject(ctx, gcpObj{obj}, bucketName, prefix, b.driver.GetBucketObject) if err != nil { return err } diff --git a/src/pkg/cli/client/byoc/parse.go b/src/pkg/cli/client/byoc/parse.go new file mode 100644 index 000000000..0b9263127 --- /dev/null +++ b/src/pkg/cli/client/byoc/parse.go @@ -0,0 +1,57 @@ +package byoc + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/DefangLabs/defang/src/pkg/term" +) + +type Obj interface { + Name() string + Size() int64 +} + +func ParsePulumiStackObject(ctx context.Context, obj Obj, bucket, prefix string, objLoader func(ctx context.Context, bucket, object string) ([]byte, error)) (string, error) { + // 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 + if !strings.HasSuffix(obj.Name(), ".json") || obj.Size() < 700 { + return "", nil + } + // Cut off the prefix and the .json suffix + stack := (obj.Name())[len(prefix) : len(obj.Name())-5] + // Check the contents of the JSON file, because the size is not a reliable indicator of a valid stack + data, err := objLoader(ctx, bucket, obj.Name()) + if err != nil { + return "", fmt.Errorf("failed to get Pulumi state object %q: %w", obj.Name(), err) + } + var state struct { + Version int `json:"version"` + Checkpoint struct { + // Stack string `json:"stack"` TODO: could use this instead of deriving the stack name from the key + Latest struct { + Resources []struct{} `json:"resources,omitempty"` + PendingOperations []struct { + Resource struct { + Urn string `json:"urn"` + } + } `json:"pending_operations,omitempty"` + } + } + } + if err := json.Unmarshal(data, &state); err != nil { + return "", fmt.Errorf("failed to decode Pulumi state %q: %w", obj.Name(), err) + } else if state.Version != 3 { + term.Debug("Skipping Pulumi state with version", state.Version) + } else if len(state.Checkpoint.Latest.PendingOperations) > 0 { + for _, op := range state.Checkpoint.Latest.PendingOperations { + parts := strings.Split(op.Resource.Urn, "::") // prefix::project::type::resource => urn:provider:stack::project::plugin:file:class::name + stack += fmt.Sprintf(" (pending %q)", parts[3]) + } + } else if len(state.Checkpoint.Latest.Resources) == 0 { + return "", nil // skip: no resources and no pending operations + } + + return stack, nil +} diff --git a/src/pkg/clouds/do/appPlatform/setup.go b/src/pkg/clouds/do/appPlatform/setup.go index 8997f0b1b..b5ae95be5 100644 --- a/src/pkg/clouds/do/appPlatform/setup.go +++ b/src/pkg/clouds/do/appPlatform/setup.go @@ -67,7 +67,7 @@ func (d *DoApp) GetBucketName(ctx context.Context, s3Client *s3.Client) (string, return bucketName, nil } -func (d *DoApp) SetUp(ctx context.Context) error { +func (d *DoApp) SetUpBucket(ctx context.Context) error { s3Client, err := d.CreateS3Client() if err != nil { return err From 51de1603f227862b10df5c67710eac3ab3143bd1 Mon Sep 17 00:00:00 2001 From: Lionello Lunesu <lio+git@lunesu.com> Date: Fri, 13 Dec 2024 09:54:15 -0800 Subject: [PATCH 2/3] Update godo: use UpdateAllSourceVersions --- src/go.mod | 6 +++--- src/go.sum | 12 ++++++------ src/pkg/cli/client/byoc/do/byoc.go | 1 + src/pkg/clouds/do/appPlatform/setup.go | 3 ++- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/go.mod b/src/go.mod index 6423dfdd3..b7e87b2c3 100644 --- a/src/go.mod +++ b/src/go.mod @@ -25,7 +25,7 @@ require ( github.com/awslabs/goformation/v7 v7.13.1 github.com/bufbuild/connect-go v1.10.0 github.com/compose-spec/compose-go/v2 v2.4.3 - github.com/digitalocean/godo v1.118.0 + github.com/digitalocean/godo v1.131.1 github.com/docker/docker v25.0.6+incompatible github.com/google/uuid v1.6.0 github.com/googleapis/gax-go/v2 v2.13.0 @@ -42,7 +42,7 @@ require ( github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 golang.org/x/mod v0.17.0 - golang.org/x/oauth2 v0.23.0 + golang.org/x/oauth2 v0.24.0 golang.org/x/sys v0.26.0 golang.org/x/term v0.25.0 google.golang.org/api v0.203.0 @@ -143,6 +143,6 @@ require ( golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 // indirect; compose-go is using the older slices.sortFunc API golang.org/x/sync v0.8.0 // indirect golang.org/x/text v0.19.0 // indirect - golang.org/x/time v0.7.0 // indirect + golang.org/x/time v0.8.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect ) diff --git a/src/go.sum b/src/go.sum index 8b5c76d5a..cb5e2ed5a 100644 --- a/src/go.sum +++ b/src/go.sum @@ -124,8 +124,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/digitalocean/godo v1.118.0 h1:lkzGFQmACrVCp7UqH1sAi4JK/PWwlc5aaxubgorKmC4= -github.com/digitalocean/godo v1.118.0/go.mod h1:Vk0vpCot2HOAJwc5WE8wljZGtJ3ZtWIc8MQ8rF38sdo= +github.com/digitalocean/godo v1.131.1 h1:2QsRwjNukKgOQbflMxOsTDoC05o5UKBpqQMFKXegYKE= +github.com/digitalocean/godo v1.131.1/go.mod h1:PU8JB6I1XYkQIdHFop8lLAY9ojp6M0XcU0TWaQSxbrc= github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/docker v25.0.6+incompatible h1:5cPwbwriIcsua2REJe8HqQV+6WlWc1byg2QSXzBxBGg= @@ -360,8 +360,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= -golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -395,8 +395,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= -golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= -golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= diff --git a/src/pkg/cli/client/byoc/do/byoc.go b/src/pkg/cli/client/byoc/do/byoc.go index 9b680e4ce..b1aab6435 100644 --- a/src/pkg/cli/client/byoc/do/byoc.go +++ b/src/pkg/cli/client/byoc/do/byoc.go @@ -420,6 +420,7 @@ func (b *ByocDo) Follow(ctx context.Context, req *defangv1.TailRequest) (client. case godo.DeploymentPhase_Error, godo.DeploymentPhase_Canceled: if logType.Has(logs.LogTypeBuild) { + // TODO: provide component name logs, _, err := b.client.Apps.GetLogs(ctx, appID, deploymentID, "", godo.AppLogTypeDeploy, true, 50) if err != nil { return nil, err diff --git a/src/pkg/clouds/do/appPlatform/setup.go b/src/pkg/clouds/do/appPlatform/setup.go index b5ae95be5..d7c4b904d 100644 --- a/src/pkg/clouds/do/appPlatform/setup.go +++ b/src/pkg/clouds/do/appPlatform/setup.go @@ -167,7 +167,8 @@ func (d DoApp) Run(ctx context.Context, env []*godo.AppVariableDefinition, cdIma if currentCd.Spec != nil && currentCd.Spec.Name != "" { term.Debugf("Updating existing CD app") currentCd, _, err = client.Apps.Update(ctx, currentCd.ID, &godo.AppUpdateRequest{ - Spec: appJobSpec, + Spec: appJobSpec, + UpdateAllSourceVersions: true, // force update of the CD image }) if err != nil { From 041869e2d05f0f6ee4b3be72b666d142ec00afbe Mon Sep 17 00:00:00 2001 From: Lionello Lunesu <lio+git@lunesu.com> Date: Fri, 13 Dec 2024 13:50:48 -0800 Subject: [PATCH 3/3] wip --- src/pkg/cli/client/byoc/do/byoc.go | 11 ++++++++++- src/pkg/cli/client/byoc/do/stream.go | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/pkg/cli/client/byoc/do/byoc.go b/src/pkg/cli/client/byoc/do/byoc.go index b1aab6435..e6c330474 100644 --- a/src/pkg/cli/client/byoc/do/byoc.go +++ b/src/pkg/cli/client/byoc/do/byoc.go @@ -415,9 +415,18 @@ func (b *ByocDo) Follow(ctx context.Context, req *defangv1.TailRequest) (client. term.Debugf("Deployment phase: %s", deploymentInfo.GetPhase()) switch deploymentInfo.GetPhase() { - case godo.DeploymentPhase_PendingBuild, godo.DeploymentPhase_PendingDeploy, godo.DeploymentPhase_Deploying: + case godo.DeploymentPhase_PendingBuild, godo.DeploymentPhase_PendingDeploy: // Do nothing; check again in 10 seconds + case godo.DeploymentPhase_Deploying: + if logType.Has(logs.LogTypeBuild) { + logs, _, err := b.client.Apps.GetLogs(ctx, appID, deploymentID, appPlatform.CdName, godo.AppLogTypeDeploy, true, 50) + if err != nil { + return nil, err + } + return newByocServerStream(ctx, logs.LiveURL, req.Etag) + } + case godo.DeploymentPhase_Error, godo.DeploymentPhase_Canceled: if logType.Has(logs.LogTypeBuild) { // TODO: provide component name diff --git a/src/pkg/cli/client/byoc/do/stream.go b/src/pkg/cli/client/byoc/do/stream.go index a02797fc8..9a95a0fe1 100644 --- a/src/pkg/cli/client/byoc/do/stream.go +++ b/src/pkg/cli/client/byoc/do/stream.go @@ -26,7 +26,7 @@ type byocServerStream struct { } func newByocServerStream(ctx context.Context, liveUrl string, etag types.ETag) (*byocServerStream, error) { - if liveUrl == "none" { + if liveUrl == "" { return &byocServerStream{}, nil }