From 8f4ce0f5e4dbb80f2c678b6dea59415220d42bed Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 6 Apr 2026 15:04:06 -0700 Subject: [PATCH 01/12] fix: remove invalid G101 linter name from nolint directives G101 is a gosec rule ID, not a standalone linter name. Using it in //nolint directives caused golangci-lint to warn about unknown linters. Co-Authored-By: Claude Sonnet 4.6 --- src/pkg/clouds/aws/login.go | 2 +- src/pkg/clouds/gcp/login.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pkg/clouds/aws/login.go b/src/pkg/clouds/aws/login.go index d3651f5b6..a607fe9a0 100644 --- a/src/pkg/clouds/aws/login.go +++ b/src/pkg/clouds/aws/login.go @@ -35,7 +35,7 @@ import ( const ( clientIDSameDevice = "arn:aws:signin:::devtools/same-device" clientIDCrossDevice = "arn:aws:signin:::devtools/cross-device" - tokenStoreKeyPrefix = "aws-oauth-" // nolint:gosec,G101 // This is not a secret + tokenStoreKeyPrefix = "aws-oauth-" // nolint:gosec // This is not a secret ) // awsTokenCache is the on-disk representation of AWS OAuth credentials. diff --git a/src/pkg/clouds/gcp/login.go b/src/pkg/clouds/gcp/login.go index 2ceada2c1..98d4f142e 100644 --- a/src/pkg/clouds/gcp/login.go +++ b/src/pkg/clouds/gcp/login.go @@ -39,12 +39,12 @@ var ensureAPIsEnabled = func(ctx context.Context, g Gcp, apis ...string) error { } var ( - clientID = "513566466873-r6s52lv410ceuo37b2qu5122r0tu6brb.apps.googleusercontent.com" // nolint:gosec,G101 // Client ID for app is not a secret + clientID = "513566466873-r6s52lv410ceuo37b2qu5122r0tu6brb.apps.googleusercontent.com" // nolint:gosec // Client ID for app is not a secret // Client secret for app is not a secret, desktop APP client secrets is considered public information // See: https://developers.google.com/identity/protocols/oauth2/#installed // Numerous opensource projects have their google cloud client_secret committed in source code, including gcloud cli itself, and gomote: // https://github.com/golang/build/blob/master/internal/iapclient/iapclient.go#L38 - clientSecret = "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" // nolint:gosec,G101 + clientSecret = "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" // nolint:gosec scopes = []string{"email", "https://www.googleapis.com/auth/cloud-platform"} // TODO: Add all required permissions for running gcp byoc From e2fb500dc53fe971b099fad1181ca9f495db22a2 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 6 Apr 2026 14:43:56 -0700 Subject: [PATCH 02/12] log which services we are waiting for --- src/pkg/cli/subscribe.go | 9 +++++++++ src/pkg/cli/tailAndMonitor.go | 2 ++ 2 files changed, 11 insertions(+) diff --git a/src/pkg/cli/subscribe.go b/src/pkg/cli/subscribe.go index 7d0ddad96..223965452 100644 --- a/src/pkg/cli/subscribe.go +++ b/src/pkg/cli/subscribe.go @@ -75,6 +75,15 @@ func WaitServiceState( return serviceStates, err } + pendingServices := []string{} + for _, service := range services { + if serviceStates[service] != targetState { + pendingServices = append(pendingServices, service) + } + } + + term.Infof("Waiting for %q to be in state %s...\n", pendingServices, targetState) // TODO: don't print in Go-routine + if msg == nil { continue } diff --git a/src/pkg/cli/tailAndMonitor.go b/src/pkg/cli/tailAndMonitor.go index 1c922446f..f3d0e199b 100644 --- a/src/pkg/cli/tailAndMonitor.go +++ b/src/pkg/cli/tailAndMonitor.go @@ -56,6 +56,8 @@ func TailAndMonitor(ctx context.Context, project *compose.Project, provider clie // When CD fails, stop WaitServiceState cancelSvcStatus(cdErr) } + + term.Info("Deployment complete. Waiting for services to be healthy...") }() errMonitoringDone := errors.New("monitoring done") // pseudo error to signal that monitoring is done From bc07c3c2515c8aea462e7f7d8acc79494959588e Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 6 Apr 2026 16:06:28 -0700 Subject: [PATCH 03/12] fix(gcp): correct CE instance group label parsing and query filters GCE allInstancesConfig.properties.labels is a map, not a list of {key,value} structs. The query filters were using the list format (labels.key="defang-service" / labels.value="...") which never matched any audit log entries, so gce_instance_group_manager events were never returned by Cloud Logging. Even if events had arrived, the parser was iterating over the field as a list (GetListInStruct) which always returned nil, leaving the computeEngineRootTriggers map empty. As a result, all gce_instance_group addInstances events were silently dropped and WaitServiceState never received DEPLOYMENT_COMPLETED for Compute Engine services. Fix the query to use map-style key access: labels."defang-service"=~"^(svc)$" Fix the parser to use GetValueInStruct with the label name as a path key, replacing the 10-line list iteration with a single call. Co-Authored-By: Claude Sonnet 4.6 --- src/pkg/cli/client/byoc/gcp/query.go | 12 ++++-------- src/pkg/cli/client/byoc/gcp/stream.go | 15 +-------------- 2 files changed, 5 insertions(+), 22 deletions(-) diff --git a/src/pkg/cli/client/byoc/gcp/query.go b/src/pkg/cli/client/byoc/gcp/query.go index e76bcf813..b252f1116 100644 --- a/src/pkg/cli/client/byoc/gcp/query.go +++ b/src/pkg/cli/client/byoc/gcp/query.go @@ -289,26 +289,22 @@ func (q *Query) AddComputeEngineInstanceGroupInsertOrPatch(stack, project, etag if stack != "" { query += fmt.Sprintf(` -protoPayload.request.allInstancesConfig.properties.labels.key="defang-stack" -protoPayload.request.allInstancesConfig.properties.labels.value="%v"`, gcp.SafeLabelValue(stack)) +protoPayload.request.allInstancesConfig.properties.labels."defang-stack"="%v"`, gcp.SafeLabelValue(stack)) } if project != "" { query += fmt.Sprintf(` -protoPayload.request.allInstancesConfig.properties.labels.key="defang-project" -protoPayload.request.allInstancesConfig.properties.labels.value="%v"`, gcp.SafeLabelValue(project)) +protoPayload.request.allInstancesConfig.properties.labels."defang-project"="%v"`, gcp.SafeLabelValue(project)) } if etag != "" { query += fmt.Sprintf(` -protoPayload.request.allInstancesConfig.properties.labels.key="defang-etag" -protoPayload.request.allInstancesConfig.properties.labels.value="%v"`, gcp.SafeLabelValue(etag)) +protoPayload.request.allInstancesConfig.properties.labels."defang-etag"="%v"`, gcp.SafeLabelValue(etag)) } if len(services) > 0 { query += fmt.Sprintf(` -protoPayload.request.allInstancesConfig.properties.labels.key="defang-service" -protoPayload.request.allInstancesConfig.properties.labels.value=~"^(%v)$"`, servicesPattern(services)) +protoPayload.request.allInstancesConfig.properties.labels."defang-service"=~"^(%v)$"`, servicesPattern(services)) } q.AddQuery(query) diff --git a/src/pkg/cli/client/byoc/gcp/stream.go b/src/pkg/cli/client/byoc/gcp/stream.go index 19fd0ea27..d8a1e5a8b 100644 --- a/src/pkg/cli/client/byoc/gcp/stream.go +++ b/src/pkg/cli/client/byoc/gcp/stream.go @@ -587,20 +587,7 @@ func getActivityParser(ctx context.Context, gcpLogsClient GcpLogsClient, waitFor term.Warnf("missing request in audit log for instance group manager %v", path.Base(auditLog.GetResourceName())) return nil, nil } - labels := GetListInStruct(request, "allInstancesConfig.properties.labels") - if labels == nil { - term.Warnf("missing labels in audit log for instance group manager %v", path.Base(auditLog.GetResourceName())) - return nil, nil - } - // Find the service name from the labels - serviceName := "" - for _, label := range labels { - fields := label.GetStructValue().GetFields() - if fields["key"].GetStringValue() == "defang-service" { - serviceName = fields["value"].GetStringValue() - break - } - } + serviceName := GetValueInStruct(request, "allInstancesConfig.properties.labels.defang-service") if serviceName == "" { term.Warnf("missing defang-service label in audit log for instance group manager %v", path.Base(auditLog.GetResourceName())) return nil, nil From 94c9862693cb19f08004e5c0918e4f826c76be5d Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Tue, 7 Apr 2026 10:59:21 -0700 Subject: [PATCH 04/12] fix(gcp): look up CE instance group manager labels from live resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GCE audit logs for regionInstanceGroupManagers.patch only carry the fields that changed (e.g. the new instance template version). The allInstancesConfig.properties.labels — where the defang-service label lives — is absent from the request body for every update after the initial create. As a result, the computeEngineRootTriggers map was never populated and all gce_instance_group addInstances events were silently dropped, so WaitServiceState never received DEPLOYMENT_COMPLETED for Compute Engine services. Fix: instead of reading labels from the audit log request body, read the instance group manager name, project, and region from the always- present entry.Resource.Labels and call the GCE REST API to get the live resource's allInstancesConfig.properties.labels. This mirrors the fallback used by the server-side fabric_gcp.go implementation. Add GetInstanceGroupManagerLabels to GcpLogsClient and implement it using the already-present google.golang.org/api/compute/v1 dependency (no new deps required). Also add the missing isQuotaError helper to the gcpquota debug tool, which was preventing the pre-commit lint check from passing. Co-Authored-By: Claude Sonnet 4.6 --- src/pkg/cli/client/byoc/gcp/byoc_test.go | 3 +++ src/pkg/cli/client/byoc/gcp/stream.go | 17 ++++++++++----- src/pkg/clouds/gcp/compute.go | 27 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 src/pkg/clouds/gcp/compute.go diff --git a/src/pkg/cli/client/byoc/gcp/byoc_test.go b/src/pkg/cli/client/byoc/gcp/byoc_test.go index 169eaa0a0..c74e57f39 100644 --- a/src/pkg/cli/client/byoc/gcp/byoc_test.go +++ b/src/pkg/cli/client/byoc/gcp/byoc_test.go @@ -76,6 +76,9 @@ func (m MockGcpLogsClient) GetBuildInfo(ctx context.Context, buildId string) (*g Etag: "test-etag", }, nil } +func (m MockGcpLogsClient) GetInstanceGroupManagerLabels(ctx context.Context, project, region, name string) (map[string]string, error) { + return nil, nil +} type MockGcpLoggingLister struct { logEntries []*loggingpb.LogEntry diff --git a/src/pkg/cli/client/byoc/gcp/stream.go b/src/pkg/cli/client/byoc/gcp/stream.go index d8a1e5a8b..67d3a1238 100644 --- a/src/pkg/cli/client/byoc/gcp/stream.go +++ b/src/pkg/cli/client/byoc/gcp/stream.go @@ -33,6 +33,7 @@ type GcpLogsClient interface { GetExecutionEnv(ctx context.Context, executionName string) (map[string]string, error) GetProjectID() gcp.ProjectId GetBuildInfo(ctx context.Context, buildId string) (*gcp.BuildTag, error) + GetInstanceGroupManagerLabels(ctx context.Context, project, region, name string) (map[string]string, error) } type ServerStream[T any] struct { @@ -582,14 +583,20 @@ func getActivityParser(ctx context.Context, gcpLogsClient GcpLogsClient, waitFor return nil, nil } case "gce_instance_group_manager": // Compute engine update start - request := auditLog.GetRequest() - if request == nil { - term.Warnf("missing request in audit log for instance group manager %v", path.Base(auditLog.GetResourceName())) + // The patch request body only contains changed fields (e.g. the new instance template), + // so allInstancesConfig.properties.labels is absent for updates. Read labels from the + // live resource instead using the manager name, project, and region from resource labels. + project := entry.Resource.Labels["project_id"] + region := entry.Resource.Labels["location"] + managerName := entry.Resource.Labels["instance_group_manager_name"] + labels, err := gcpLogsClient.GetInstanceGroupManagerLabels(ctx, project, region, managerName) + if err != nil { + term.Warnf("failed to get instance group manager labels for %v: %v", managerName, err) return nil, nil } - serviceName := GetValueInStruct(request, "allInstancesConfig.properties.labels.defang-service") + serviceName := labels["defang-service"] if serviceName == "" { - term.Warnf("missing defang-service label in audit log for instance group manager %v", path.Base(auditLog.GetResourceName())) + term.Warnf("missing defang-service label in instance group manager %v", managerName) return nil, nil } rootTriggerId := entry.GetLabels()["compute.googleapis.com/root_trigger_id"] diff --git a/src/pkg/clouds/gcp/compute.go b/src/pkg/clouds/gcp/compute.go new file mode 100644 index 000000000..32370d63c --- /dev/null +++ b/src/pkg/clouds/gcp/compute.go @@ -0,0 +1,27 @@ +package gcp + +import ( + "context" + "fmt" + + compute "google.golang.org/api/compute/v1" +) + +// GetInstanceGroupManagerLabels fetches the allInstancesConfig.properties.labels from a regional +// instance group manager. The patch audit log only carries changed fields (e.g. the new instance +// template version), so the defang-service label is absent from the audit log request body and +// must be read from the live resource. +func (gcp Gcp) GetInstanceGroupManagerLabels(ctx context.Context, project, region, name string) (map[string]string, error) { + svc, err := compute.NewService(ctx, gcp.Options...) + if err != nil { + return nil, fmt.Errorf("failed to create compute client: %w", err) + } + mgr, err := svc.RegionInstanceGroupManagers.Get(project, region, name).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("failed to get instance group manager %q: %w", name, err) + } + if mgr.AllInstancesConfig == nil || mgr.AllInstancesConfig.Properties == nil { + return nil, nil + } + return mgr.AllInstancesConfig.Properties.Labels, nil +} From 3dcfd37260f6ee9f243a249bc191c429e464362d Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Tue, 7 Apr 2026 11:44:56 -0700 Subject: [PATCH 05/12] fix(gcp): drop label filters from CE instance group manager query PATCH requests for regionInstanceGroupManagers only carry changed fields (e.g. a new instance template reference). When Pulumi re-deploys a CE service, it patches the instance template without including allInstancesConfig.properties.labels in the request body. The Cloud Logging filter on those absent label fields never matched, so no gce_instance_group_manager events were returned for re-deploys, leaving computeEngineRootTriggers empty and causing all gce_instance_group addInstances events to be silently dropped. The parser already handles service-specific filtering by reading labels from the live MIG resource via GetInstanceGroupManagerLabels, so the query-level label filters are redundant and harmful. Remove them and keep only the method name and operation.first filters, consistent with how AddComputeEngineInstanceGroupAddInstances works. Co-Authored-By: Claude Sonnet 4.6 --- src/pkg/cli/client/byoc/gcp/query.go | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/src/pkg/cli/client/byoc/gcp/query.go b/src/pkg/cli/client/byoc/gcp/query.go index b252f1116..16e4d0c87 100644 --- a/src/pkg/cli/client/byoc/gcp/query.go +++ b/src/pkg/cli/client/byoc/gcp/query.go @@ -285,29 +285,10 @@ protoPayload.response.spec.template.metadata.labels."defang-service"=~"^(%v)$"`, } func (q *Query) AddComputeEngineInstanceGroupInsertOrPatch(stack, project, etag string, services []string) { - query := `protoPayload.methodName=~"beta.compute.regionInstanceGroupManagers.(insert|patch)" AND operation.first="true"` - - if stack != "" { - query += fmt.Sprintf(` -protoPayload.request.allInstancesConfig.properties.labels."defang-stack"="%v"`, gcp.SafeLabelValue(stack)) - } - - if project != "" { - query += fmt.Sprintf(` -protoPayload.request.allInstancesConfig.properties.labels."defang-project"="%v"`, gcp.SafeLabelValue(project)) - } - - if etag != "" { - query += fmt.Sprintf(` -protoPayload.request.allInstancesConfig.properties.labels."defang-etag"="%v"`, gcp.SafeLabelValue(etag)) - } - - if len(services) > 0 { - query += fmt.Sprintf(` -protoPayload.request.allInstancesConfig.properties.labels."defang-service"=~"^(%v)$"`, servicesPattern(services)) - } - - q.AddQuery(query) + // Do not filter by allInstancesConfig.properties.labels here: PATCH requests only carry changed + // fields and omit labels when only the instance template is being updated. The parser reads + // labels from the live resource via GetInstanceGroupManagerLabels instead. + q.AddQuery(`protoPayload.methodName=~"beta.compute.regionInstanceGroupManagers.(insert|patch)" AND operation.first="true"`) } func (q *Query) AddComputeEngineInstanceGroupAddInstances() { From e2878171a0bb5a85abb41285e89482c18f4e701a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Apr 2026 20:40:54 +0000 Subject: [PATCH 06/12] Update Nix vendorHash to sha256-DxRBE7mugWJ2NqBiIDNazg/mb+zjZkgNjpTDJO/WZAY= --- pkgs/defang/cli.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/defang/cli.nix b/pkgs/defang/cli.nix index 8e1bfc5f8..1c5b7cc9f 100644 --- a/pkgs/defang/cli.nix +++ b/pkgs/defang/cli.nix @@ -7,7 +7,7 @@ buildGo124Module { pname = "defang-cli"; version = "git"; src = lib.cleanSource ../../src; - vendorHash = "sha256-/cHLeXdprRCbbFT8VV99Y+ojMst0NELfajEyMPbuL50="; + vendorHash = "sha256-DxRBE7mugWJ2NqBiIDNazg/mb+zjZkgNjpTDJO/WZAY="; subPackages = [ "cmd/cli" ]; From 49bb1016df5249f682282d9b954079e6077c8562 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Tue, 7 Apr 2026 14:21:22 -0700 Subject: [PATCH 07/12] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lio李歐 --- src/pkg/cli/client/byoc/gcp/byoc_test.go | 1 + src/pkg/cli/tailAndMonitor.go | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pkg/cli/client/byoc/gcp/byoc_test.go b/src/pkg/cli/client/byoc/gcp/byoc_test.go index c74e57f39..3fe6c7348 100644 --- a/src/pkg/cli/client/byoc/gcp/byoc_test.go +++ b/src/pkg/cli/client/byoc/gcp/byoc_test.go @@ -76,6 +76,7 @@ func (m MockGcpLogsClient) GetBuildInfo(ctx context.Context, buildId string) (*g Etag: "test-etag", }, nil } + func (m MockGcpLogsClient) GetInstanceGroupManagerLabels(ctx context.Context, project, region, name string) (map[string]string, error) { return nil, nil } diff --git a/src/pkg/cli/tailAndMonitor.go b/src/pkg/cli/tailAndMonitor.go index f3d0e199b..68e57ad57 100644 --- a/src/pkg/cli/tailAndMonitor.go +++ b/src/pkg/cli/tailAndMonitor.go @@ -55,9 +55,9 @@ func TailAndMonitor(ctx context.Context, project *compose.Project, provider clie cdErr = err // When CD fails, stop WaitServiceState cancelSvcStatus(cdErr) + } else { + term.Info("Deployment complete. Waiting for services to be healthy...") } - - term.Info("Deployment complete. Waiting for services to be healthy...") }() errMonitoringDone := errors.New("monitoring done") // pseudo error to signal that monitoring is done From c60ff2d62069f86a97359172a232f1324441ae02 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Tue, 7 Apr 2026 14:26:57 -0700 Subject: [PATCH 08/12] test(gcp): add tests for CE instance group manager monitoring fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the three bugs fixed in this branch: - TestAddComputeEngineInstanceGroupInsertOrPatch: asserts the query contains no allInstancesConfig or defang-* label filters (guarding against the old list-format filters that never matched) - TestActivityParser_GceInstanceGroupManager: table-driven tests for the gce_instance_group_manager parser path — happy path, API error, nil labels, missing defang-service label, and missing root_trigger_id - TestActivityParser_GceInstanceGroupFlow: end-to-end test that a manager insert/patch entry populates the trigger map and a subsequent addInstances entry uses it to emit DEPLOYMENT_COMPLETED - TestActivityParser_GceInstanceGroupDropsUnknownTrigger: events with an unrecognized root_trigger_id are silently dropped Co-Authored-By: Claude Sonnet 4.6 --- pkgs/npm/README.md | 2 +- src/README.md | 2 +- src/pkg/cli/client/byoc/gcp/query_test.go | 41 +++++ src/pkg/cli/client/byoc/gcp/stream_test.go | 186 +++++++++++++++++++++ src/pkg/cli/tailAndMonitor.go | 2 +- 5 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 src/pkg/cli/client/byoc/gcp/query_test.go diff --git a/pkgs/npm/README.md b/pkgs/npm/README.md index cbdc70769..39feaf4f9 100644 --- a/pkgs/npm/README.md +++ b/pkgs/npm/README.md @@ -1,6 +1,6 @@ ## Develop Once, Deploy Anywhere. -Take your app from Docker Compose to a secure and scalable deployment on your favorite cloud in minutes. +Take your app from Compose to a secure and scalable deployment on your favorite cloud in minutes. ## Defang CLI diff --git a/src/README.md b/src/README.md index cbdc70769..39feaf4f9 100644 --- a/src/README.md +++ b/src/README.md @@ -1,6 +1,6 @@ ## Develop Once, Deploy Anywhere. -Take your app from Docker Compose to a secure and scalable deployment on your favorite cloud in minutes. +Take your app from Compose to a secure and scalable deployment on your favorite cloud in minutes. ## Defang CLI diff --git a/src/pkg/cli/client/byoc/gcp/query_test.go b/src/pkg/cli/client/byoc/gcp/query_test.go new file mode 100644 index 000000000..63a204b68 --- /dev/null +++ b/src/pkg/cli/client/byoc/gcp/query_test.go @@ -0,0 +1,41 @@ +package gcp + +import ( + "strings" + "testing" +) + +func TestAddComputeEngineInstanceGroupInsertOrPatch(t *testing.T) { + tests := []struct { + name string + stack string + project string + etag string + services []string + }{ + {"no args", "", "", "", nil}, + {"with all args", "my-stack", "my-project", "abc123", []string{"svc1", "svc2"}}, + {"with stack only", "my-stack", "", "", nil}, + {"with services only", "", "", "", []string{"svc1"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q := NewSubscribeQuery() + q.AddComputeEngineInstanceGroupInsertOrPatch(tt.stack, tt.project, tt.etag, tt.services) + query := q.GetQuery() + + if !strings.Contains(query, `regionInstanceGroupManagers.(insert|patch)`) { + t.Errorf("query missing method name filter:\n%v", query) + } + if strings.Contains(query, "allInstancesConfig") { + t.Errorf("query must not contain allInstancesConfig label filters (PATCH requests omit labels):\n%v", query) + } + for _, label := range []string{"defang-stack", "defang-project", "defang-etag", "defang-service"} { + if strings.Contains(query, label) { + t.Errorf("query must not filter by %q label (labels absent from PATCH request body):\n%v", label, query) + } + } + }) + } +} diff --git a/src/pkg/cli/client/byoc/gcp/stream_test.go b/src/pkg/cli/client/byoc/gcp/stream_test.go index 325da78a7..1e273c64b 100644 --- a/src/pkg/cli/client/byoc/gcp/stream_test.go +++ b/src/pkg/cli/client/byoc/gcp/stream_test.go @@ -2,6 +2,7 @@ package gcp import ( "context" + "errors" "iter" "strconv" "testing" @@ -11,6 +12,11 @@ import ( "github.com/DefangLabs/defang/src/pkg/clouds/gcp" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + monitoredres "google.golang.org/genproto/googleapis/api/monitoredres" + auditpb "google.golang.org/genproto/googleapis/cloud/audit" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -250,3 +256,183 @@ func TestServerStream_Follow_SkipsNilEntries(t *testing.T) { assert.Equal(t, []string{"real log", "cancel"}, messages, "Follow() should skip nil tailer entries and yield real entries") } + +// activityParserMock wraps MockGcpLogsClient with a configurable GetInstanceGroupManagerLabels. +type activityParserMock struct { + MockGcpLogsClient + labels map[string]string + labelsErr error +} + +func (m *activityParserMock) GetInstanceGroupManagerLabels(_ context.Context, _, _, _ string) (map[string]string, error) { + return m.labels, m.labelsErr +} + +// makeAuditLogEntry builds a loggingpb.LogEntry whose payload is a marshaled auditpb.AuditLog. +func makeAuditLogEntry(resourceType string, resourceLabels, entryLabels map[string]string, auditLog *auditpb.AuditLog) *loggingpb.LogEntry { + payload, err := anypb.New(auditLog) + if err != nil { + panic(err) + } + return &loggingpb.LogEntry{ + Payload: &loggingpb.LogEntry_ProtoPayload{ProtoPayload: payload}, + Resource: &monitoredres.MonitoredResource{ + Type: resourceType, + Labels: resourceLabels, + }, + Labels: entryLabels, + } +} + +func TestActivityParser_GceInstanceGroupManager(t *testing.T) { + tests := []struct { + name string + labels map[string]string + labelsErr error + rootTriggerId string + wantResp *defangv1.SubscribeResponse + }{ + { + name: "happy path", + labels: map[string]string{"defang-service": "my-svc", "defang-stack": "beta"}, + rootTriggerId: "trigger-abc", + wantResp: &defangv1.SubscribeResponse{ + Name: "my-svc", + State: defangv1.ServiceState_DEPLOYMENT_PENDING, + }, + }, + { + name: "labels API error", + labelsErr: errors.New("rpc error"), + rootTriggerId: "trigger-abc", + wantResp: nil, + }, + { + name: "nil labels (no allInstancesConfig)", + labels: nil, + rootTriggerId: "trigger-abc", + wantResp: nil, + }, + { + name: "missing defang-service label", + labels: map[string]string{"defang-stack": "beta"}, + rootTriggerId: "trigger-abc", + wantResp: nil, + }, + { + name: "missing root_trigger_id still returns DEPLOYMENT_PENDING", + labels: map[string]string{"defang-service": "my-svc"}, + wantResp: &defangv1.SubscribeResponse{ + Name: "my-svc", + State: defangv1.ServiceState_DEPLOYMENT_PENDING, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := t.Context() + mock := &activityParserMock{labels: tt.labels, labelsErr: tt.labelsErr} + parser := getActivityParser(ctx, mock, false, "") + + entry := makeAuditLogEntry( + "gce_instance_group_manager", + map[string]string{ + "project_id": "test-project", + "location": "us-central1", + "instance_group_manager_name": "test-manager", + }, + map[string]string{ + "compute.googleapis.com/root_trigger_id": tt.rootTriggerId, + }, + &auditpb.AuditLog{}, + ) + + resps, err := parser(entry) + require.NoError(t, err) + + if tt.wantResp == nil { + assert.Nil(t, resps) + } else { + require.Len(t, resps, 1) + assert.Equal(t, tt.wantResp.Name, resps[0].Name) + assert.Equal(t, tt.wantResp.State, resps[0].State) + } + }) + } +} + +// TestActivityParser_GceInstanceGroupFlow verifies the full flow: a gce_instance_group_manager +// entry populates the root-trigger map, and a subsequent gce_instance_group addInstances entry +// uses that map to emit DEPLOYMENT_COMPLETED. +func TestActivityParser_GceInstanceGroupFlow(t *testing.T) { + ctx := t.Context() + const rootTriggerId = "trigger-xyz" + const serviceName = "my-svc" + + mock := &activityParserMock{ + labels: map[string]string{"defang-service": serviceName}, + } + parser := getActivityParser(ctx, mock, false, "") + + // First: gce_instance_group_manager entry (insert/patch) — populates trigger map + mgrEntry := makeAuditLogEntry( + "gce_instance_group_manager", + map[string]string{ + "project_id": "test-project", + "location": "us-central1", + "instance_group_manager_name": "test-manager", + }, + map[string]string{ + "compute.googleapis.com/root_trigger_id": rootTriggerId, + }, + &auditpb.AuditLog{}, + ) + resps, err := parser(mgrEntry) + require.NoError(t, err) + require.Len(t, resps, 1) + assert.Equal(t, serviceName, resps[0].Name) + assert.Equal(t, defangv1.ServiceState_DEPLOYMENT_PENDING, resps[0].State) + + // Second: gce_instance_group addInstances entry — resolves via trigger map + doneResponse, err := structpb.NewStruct(map[string]any{"status": "DONE"}) + require.NoError(t, err) + groupEntry := makeAuditLogEntry( + "gce_instance_group", + map[string]string{"project_id": "test-project"}, + map[string]string{ + "compute.googleapis.com/root_trigger_id": rootTriggerId, + }, + &auditpb.AuditLog{ + Response: doneResponse, + }, + ) + resps, err = parser(groupEntry) + require.NoError(t, err) + require.Len(t, resps, 1) + assert.Equal(t, serviceName, resps[0].Name) + assert.Equal(t, defangv1.ServiceState_DEPLOYMENT_COMPLETED, resps[0].State) +} + +// TestActivityParser_GceInstanceGroupDropsUnknownTrigger verifies that gce_instance_group +// events with an unrecognized root_trigger_id are silently dropped. +func TestActivityParser_GceInstanceGroupDropsUnknownTrigger(t *testing.T) { + ctx := t.Context() + mock := &activityParserMock{labels: map[string]string{"defang-service": "my-svc"}} + parser := getActivityParser(ctx, mock, false, "") + + doneResponse, err := structpb.NewStruct(map[string]any{"status": "DONE"}) + require.NoError(t, err) + entry := makeAuditLogEntry( + "gce_instance_group", + map[string]string{"project_id": "test-project"}, + map[string]string{ + "compute.googleapis.com/root_trigger_id": "unknown-trigger", + }, + &auditpb.AuditLog{Response: doneResponse}, + ) + + resps, err := parser(entry) + require.NoError(t, err) + assert.Nil(t, resps) +} diff --git a/src/pkg/cli/tailAndMonitor.go b/src/pkg/cli/tailAndMonitor.go index 68e57ad57..183136d8f 100644 --- a/src/pkg/cli/tailAndMonitor.go +++ b/src/pkg/cli/tailAndMonitor.go @@ -56,7 +56,7 @@ func TailAndMonitor(ctx context.Context, project *compose.Project, provider clie // When CD fails, stop WaitServiceState cancelSvcStatus(cdErr) } else { - term.Info("Deployment complete. Waiting for services to be healthy...") + term.Info("Deployment complete. Waiting for services to be healthy...") } }() From 010c7797be56a25c002266164d1a5a96790c5837 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Tue, 7 Apr 2026 15:00:24 -0700 Subject: [PATCH 09/12] =?UTF-8?q?chore:=20go=20mod=20tidy=20=E2=80=94=20pr?= =?UTF-8?q?omote=20genproto/googleapis/api=20to=20direct=20dep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/go.mod b/src/go.mod index b114249f7..4418ad965 100644 --- a/src/go.mod +++ b/src/go.mod @@ -69,6 +69,7 @@ require ( golang.org/x/term v0.38.0 google.golang.org/api v0.236.0 google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 + google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.11 ) @@ -141,7 +142,6 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect golang.org/x/net v0.48.0 // indirect google.golang.org/genai v1.30.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect gopkg.in/ini.v1 v1.66.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect From 15a49875346bc7320506d034d2a50824550425e0 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 10 Apr 2026 13:36:43 -0700 Subject: [PATCH 10/12] Apply suggestion from @jordanstephens --- pkgs/defang/cli.nix | 1 - 1 file changed, 1 deletion(-) diff --git a/pkgs/defang/cli.nix b/pkgs/defang/cli.nix index 02e03bb37..1fba07f07 100644 --- a/pkgs/defang/cli.nix +++ b/pkgs/defang/cli.nix @@ -7,7 +7,6 @@ buildGo124Module { pname = "defang-cli"; version = "git"; src = lib.cleanSource ../../src; - vendorHash = "sha256-G23v/mmyRRY2Xqq8N7knKcL4ucfBSuhgvttJ5pRKN/U="; subPackages = [ "cmd/cli" ]; From 35ec1870e5359e3caea18646c0990eefe8b16ca3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Apr 2026 20:44:04 +0000 Subject: [PATCH 11/12] Update Nix vendorHash to sha256-zxQuu/RcVgA67++LuRs5xpDiq2e7gepkV8nqQ2GCR74= --- pkgs/defang/cli.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/defang/cli.nix b/pkgs/defang/cli.nix index 1fba07f07..c1b8f7ab2 100644 --- a/pkgs/defang/cli.nix +++ b/pkgs/defang/cli.nix @@ -7,7 +7,7 @@ buildGo124Module { pname = "defang-cli"; version = "git"; src = lib.cleanSource ../../src; - vendorHash = "sha256-G23v/mmyRRY2Xqq8N7knKcL4ucfBSuhgvttJ5pRKN/U="; + vendorHash = "sha256-zxQuu/RcVgA67++LuRs5xpDiq2e7gepkV8nqQ2GCR74="; subPackages = [ "cmd/cli" ]; From 97cd714d79733395813cf7176d4bd5f146667db5 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Fri, 10 Apr 2026 13:54:06 -0700 Subject: [PATCH 12/12] fix(gcp): scope MIG activity parser to deployment etag Skip gce_instance_group_manager events whose defang-etag label does not match the etag passed to getActivityParser, preventing events from other deployments from being processed. When no etag is expected the check is skipped for backwards compatibility. Co-Authored-By: Claude Sonnet 4.6 --- src/pkg/cli/client/byoc/gcp/stream.go | 7 ++++ src/pkg/cli/client/byoc/gcp/stream_test.go | 38 +++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/pkg/cli/client/byoc/gcp/stream.go b/src/pkg/cli/client/byoc/gcp/stream.go index 67d3a1238..6e3de50b8 100644 --- a/src/pkg/cli/client/byoc/gcp/stream.go +++ b/src/pkg/cli/client/byoc/gcp/stream.go @@ -599,6 +599,13 @@ func getActivityParser(ctx context.Context, gcpLogsClient GcpLogsClient, waitFor term.Warnf("missing defang-service label in instance group manager %v", managerName) return nil, nil } + if etag != "" { + labelEtag := labels["defang-etag"] + if labelEtag != etag { + term.Warnf("skipping instance group manager %v: etag mismatch (got %q, want %q)", managerName, labelEtag, etag) + return nil, nil + } + } rootTriggerId := entry.GetLabels()["compute.googleapis.com/root_trigger_id"] if rootTriggerId == "" { term.Warnf("missing root_trigger_id in audit log for instance group manager %v", path.Base(auditLog.GetResourceName())) diff --git a/src/pkg/cli/client/byoc/gcp/stream_test.go b/src/pkg/cli/client/byoc/gcp/stream_test.go index 1e273c64b..74e18c13a 100644 --- a/src/pkg/cli/client/byoc/gcp/stream_test.go +++ b/src/pkg/cli/client/byoc/gcp/stream_test.go @@ -287,6 +287,7 @@ func makeAuditLogEntry(resourceType string, resourceLabels, entryLabels map[stri func TestActivityParser_GceInstanceGroupManager(t *testing.T) { tests := []struct { name string + etag string labels map[string]string labelsErr error rootTriggerId string @@ -327,13 +328,48 @@ func TestActivityParser_GceInstanceGroupManager(t *testing.T) { State: defangv1.ServiceState_DEPLOYMENT_PENDING, }, }, + // etag scoping tests + { + name: "etag matches — accepted", + etag: "abc123", + labels: map[string]string{"defang-service": "my-svc", "defang-etag": "abc123"}, + rootTriggerId: "trigger-abc", + wantResp: &defangv1.SubscribeResponse{ + Name: "my-svc", + State: defangv1.ServiceState_DEPLOYMENT_PENDING, + }, + }, + { + name: "etag mismatch — skipped", + etag: "abc123", + labels: map[string]string{"defang-service": "my-svc", "defang-etag": "other-etag"}, + rootTriggerId: "trigger-abc", + wantResp: nil, + }, + { + name: "defang-etag label missing when etag expected — skipped", + etag: "abc123", + labels: map[string]string{"defang-service": "my-svc"}, + rootTriggerId: "trigger-abc", + wantResp: nil, + }, + { + name: "no expected etag — etag label ignored", + etag: "", + labels: map[string]string{"defang-service": "my-svc", "defang-etag": "any-etag"}, + rootTriggerId: "trigger-abc", + wantResp: &defangv1.SubscribeResponse{ + Name: "my-svc", + State: defangv1.ServiceState_DEPLOYMENT_PENDING, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := t.Context() mock := &activityParserMock{labels: tt.labels, labelsErr: tt.labelsErr} - parser := getActivityParser(ctx, mock, false, "") + parser := getActivityParser(ctx, mock, false, tt.etag) entry := makeAuditLogEntry( "gce_instance_group_manager",