From 1c7d3b8fb99b10afed14d108b1dd270ad7384973 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 6 Apr 2026 15:04:06 -0700 Subject: [PATCH 01/22] 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 780617d6c0e5bf9131e720284760bda0405f777a Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 6 Apr 2026 14:43:56 -0700 Subject: [PATCH 02/22] 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 c36cc3b263aa7d196e4bd7cd4df0d774f04684e5 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 6 Apr 2026 16:06:28 -0700 Subject: [PATCH 03/22] 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 acc789f461b0533469c184e42f8aeddd49e336d2 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Tue, 7 Apr 2026 10:59:21 -0700 Subject: [PATCH 04/22] 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 92d5cd36b649c05ab7b1f69b4fbb0a0174f4f852 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Tue, 7 Apr 2026 11:44:56 -0700 Subject: [PATCH 05/22] 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 19a40d6272ba3b0bccb8aa6823d3760f632815d2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Apr 2026 20:40:54 +0000 Subject: [PATCH 06/22] 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 1fba07f07..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-G23v/mmyRRY2Xqq8N7knKcL4ucfBSuhgvttJ5pRKN/U="; + vendorHash = "sha256-DxRBE7mugWJ2NqBiIDNazg/mb+zjZkgNjpTDJO/WZAY="; subPackages = [ "cmd/cli" ]; From 2eb94e82ec8b56b6c3e30bc19b1d5e6e7473579a Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Tue, 7 Apr 2026 14:21:22 -0700 Subject: [PATCH 07/22] 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 9ea097258d649f6193b6b356d4c0493f7d4a10ac Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Tue, 7 Apr 2026 14:26:57 -0700 Subject: [PATCH 08/22] 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 --- 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 +- 3 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 src/pkg/cli/client/byoc/gcp/query_test.go 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 4f691f2adf98c2a0f634f81dd66d1221c3f985e4 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Tue, 7 Apr 2026 15:00:24 -0700 Subject: [PATCH 09/22] =?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 f34dd1dcf..f1d86e5ba 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 c89c2d227df63cace70418019d868cbba49b76cd Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Wed, 8 Apr 2026 11:39:52 -0700 Subject: [PATCH 10/22] refactor info message to have fixed prefix Instead of * Waiting for ["app" "worker" "chat" "embedding"] to be in state DEPLOYMENT_COMPLETED... * Waiting for ["app" "worker" "embedding"] to be in state DEPLOYMENT_COMPLETED... * Waiting for ["app" "worker"] to be in state DEPLOYMENT_COMPLETED... I prefer * Waiting for services to finish deploying: ["app" "worker" "chat" "embedding"] * Waiting for services to finish deploying: ["app" "worker" "embedding"] * Waiting for services to finish deploying: ["app" "worker"] message --- src/pkg/cli/subscribe.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pkg/cli/subscribe.go b/src/pkg/cli/subscribe.go index 223965452..b143e5712 100644 --- a/src/pkg/cli/subscribe.go +++ b/src/pkg/cli/subscribe.go @@ -82,7 +82,7 @@ func WaitServiceState( } } - term.Infof("Waiting for %q to be in state %s...\n", pendingServices, targetState) // TODO: don't print in Go-routine + term.Infof("Waiting for services to finish deploying: %q\n", pendingServices) // TODO: don't print in Go-routine if msg == nil { continue From 669a060582830cc164b3b9584e5a0d25e3601932 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Wed, 8 Apr 2026 11:41:52 -0700 Subject: [PATCH 11/22] make service status updates more easily grep-able i always find myself grepping for 'with state' which only feels tangentially related. A better prefix is "Service update:" --- src/pkg/cli/subscribe.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pkg/cli/subscribe.go b/src/pkg/cli/subscribe.go index b143e5712..6b0b95d4a 100644 --- a/src/pkg/cli/subscribe.go +++ b/src/pkg/cli/subscribe.go @@ -88,7 +88,7 @@ func WaitServiceState( continue } - term.Debugf("service %s with state ( %s ) and status: %s\n", msg.Name, msg.State, msg.Status) // TODO: don't print in Go-routine + term.Debugf("Service update: %s: state=%s and status=%s\n", msg.Name, msg.State, msg.Status) // TODO: don't print in Go-routine if _, ok := serviceStates[msg.Name]; !ok { term.Debugf("unexpected service %s update", msg.Name) // TODO: don't print in Go-routine From 07d1d0408e3761e43ccbb93127660db863feeb36 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Thu, 2 Apr 2026 16:50:56 -0700 Subject: [PATCH 12/22] replace openai-access-gateway with litellm --- src/pkg/cli/compose/fixup.go | 14 ++++++----- src/testdata/llm/compose.yaml | 10 +++----- src/testdata/llm/compose.yaml.fixup | 32 +++++++++--------------- src/testdata/llm/compose.yaml.golden | 11 +++----- src/testdata/llm/compose.yaml.warnings | 1 - src/testdata/models/compose.yaml.fixup | 24 ++++++++++++------ src/testdata/provider/compose.yaml.fixup | 12 ++++++--- 7 files changed, 50 insertions(+), 54 deletions(-) diff --git a/src/pkg/cli/compose/fixup.go b/src/pkg/cli/compose/fixup.go index 7b3e02f86..0953e08b3 100644 --- a/src/pkg/cli/compose/fixup.go +++ b/src/pkg/cli/compose/fixup.go @@ -229,9 +229,10 @@ func parsePortString(port string) (uint32, error) { func fixupLLM(svccfg *composeTypes.ServiceConfig) { image := GetImageRepo(svccfg.Image) - if strings.HasSuffix(image, "/openai-access-gateway") && len(svccfg.Ports) == 0 { + if strings.HasSuffix(image, "/litellm") && len(svccfg.Ports) == 0 { // HACK: we must have at least one host port to get a CNAME for the service - var port uint32 = 80 + // litellm listens on 4000 by default + var port uint32 = 4000 term.Debugf("service %q: adding LLM host port %d", svccfg.Name, port) svccfg.Ports = []composeTypes.ServicePortConfig{{Target: port, Mode: Mode_HOST, Protocol: Protocol_TCP}} } @@ -368,11 +369,12 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo if svccfg.Environment == nil { svccfg.Environment = composeTypes.MappingWithEquals{} } - if _, exists := svccfg.Environment["OPENAI_API_KEY"]; !exists { - svccfg.Environment["OPENAI_API_KEY"] = &empty // disable auth; see https://github.com/DefangLabs/openai-access-gateway/pull/5 + if _, exists := svccfg.Environment["LITELLM_MASTER_KEY"]; !exists { + svccfg.Environment["LITELLM_MASTER_KEY"] = &empty // disable auth; see https://github.com/DefangLabs/openai-access-gateway/pull/5 } // svccfg.HealthCheck = &composeTypes.ServiceHealthCheckConfig{} TODO: add healthcheck - svccfg.Image = "defangio/openai-access-gateway" + svccfg.Image = "litellm/litellm:latest" + svccfg.Command = []string{"--drop_params", "--model", model} if svccfg.Networks == nil { // New compose-go versions do not create networks for "provider:" services, so we need to create it here svccfg.Networks = make(map[string]*composeTypes.ServiceNetworkConfig) @@ -380,7 +382,7 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo delete(svccfg.Networks, "default") // remove the default network } svccfg.Networks[modelProviderNetwork] = nil - svccfg.Ports = []composeTypes.ServicePortConfig{{Target: 80, Mode: Mode_HOST, Protocol: Protocol_TCP}} + svccfg.Ports = []composeTypes.ServicePortConfig{{Target: 4000, Mode: Mode_HOST, Protocol: Protocol_TCP}} svccfg.Provider = nil // remove "provider:" because current backend will not accept it project.Networks[modelProviderNetwork] = composeTypes.NetworkConfig{Name: modelProviderNetwork} diff --git a/src/testdata/llm/compose.yaml b/src/testdata/llm/compose.yaml index f04919146..d8501357f 100644 --- a/src/testdata/llm/compose.yaml +++ b/src/testdata/llm/compose.yaml @@ -1,18 +1,14 @@ services: llm: x-defang-llm: true - image: "llm:latest" + image: "litellm/litellm:latest" gateway-with-ports: x-defang-llm: true - image: "defang.io/openai-access-gateway:latest" + image: "litellm/litellm:latest" ports: - 5678:5678 gateway-without-ports: x-defang-llm: true - image: "defang.io/openai-access-gateway:latest" - - alt-repo: - x-defang-llm: true - image: "altrepo.com/openai-access-gateway:latest" + image: "litellm/litellm:latest" diff --git a/src/testdata/llm/compose.yaml.fixup b/src/testdata/llm/compose.yaml.fixup index ccc27e420..3dccd64ac 100644 --- a/src/testdata/llm/compose.yaml.fixup +++ b/src/testdata/llm/compose.yaml.fixup @@ -1,23 +1,8 @@ { - "alt-repo": { - "command": null, - "entrypoint": null, - "image": "altrepo.com/openai-access-gateway:latest", - "networks": { - "default": null - }, - "ports": [ - { - "mode": "host", - "target": 80, - "protocol": "tcp" - } - ] - }, "gateway-with-ports": { "command": null, "entrypoint": null, - "image": "defang.io/openai-access-gateway:latest", + "image": "litellm/litellm:latest", "networks": { "default": null }, @@ -34,14 +19,14 @@ "gateway-without-ports": { "command": null, "entrypoint": null, - "image": "defang.io/openai-access-gateway:latest", + "image": "litellm/litellm:latest", "networks": { "default": null }, "ports": [ { "mode": "host", - "target": 80, + "target": 4000, "protocol": "tcp" } ] @@ -49,9 +34,16 @@ "llm": { "command": null, "entrypoint": null, - "image": "llm:latest", + "image": "litellm/litellm:latest", "networks": { "default": null - } + }, + "ports": [ + { + "mode": "host", + "target": 4000, + "protocol": "tcp" + } + ] } } \ No newline at end of file diff --git a/src/testdata/llm/compose.yaml.golden b/src/testdata/llm/compose.yaml.golden index 465ff9246..07844da8b 100644 --- a/src/testdata/llm/compose.yaml.golden +++ b/src/testdata/llm/compose.yaml.golden @@ -1,12 +1,7 @@ name: llm services: - alt-repo: - image: altrepo.com/openai-access-gateway:latest - networks: - default: null - x-defang-llm: true gateway-with-ports: - image: defang.io/openai-access-gateway:latest + image: litellm/litellm:latest networks: default: null ports: @@ -16,12 +11,12 @@ services: protocol: tcp x-defang-llm: true gateway-without-ports: - image: defang.io/openai-access-gateway:latest + image: litellm/litellm:latest networks: default: null x-defang-llm: true llm: - image: llm:latest + image: litellm/litellm:latest networks: default: null x-defang-llm: true diff --git a/src/testdata/llm/compose.yaml.warnings b/src/testdata/llm/compose.yaml.warnings index d6021d45a..1f7003885 100644 --- a/src/testdata/llm/compose.yaml.warnings +++ b/src/testdata/llm/compose.yaml.warnings @@ -1,4 +1,3 @@ - ! service "alt-repo": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors ! service "gateway-with-ports": ingress port 5678 without healthcheck; defaults to GET / HTTP/1.1 ! service "gateway-with-ports": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors ! service "gateway-without-ports": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors diff --git a/src/testdata/models/compose.yaml.fixup b/src/testdata/models/compose.yaml.fixup index 02574ebe9..d3ef2ac18 100644 --- a/src/testdata/models/compose.yaml.fixup +++ b/src/testdata/models/compose.yaml.fixup @@ -1,18 +1,22 @@ { "ai_model": { - "command": null, + "command": [ + "--drop_params", + "--model", + "ai/model" + ], "entrypoint": null, "environment": { - "OPENAI_API_KEY": "" + "LITELLM_MASTER_KEY": "" }, - "image": "defangio/openai-access-gateway", + "image": "litellm/litellm:latest", "networks": { "model_provider_private": null }, "ports": [ { "mode": "host", - "target": 80, + "target": 4000, "protocol": "tcp" } ] @@ -50,19 +54,23 @@ } }, "my_model": { - "command": null, + "command": [ + "--drop_params", + "--model", + "ai/model" + ], "entrypoint": null, "environment": { - "OPENAI_API_KEY": "" + "LITELLM_MASTER_KEY": "" }, - "image": "defangio/openai-access-gateway", + "image": "litellm/litellm:latest", "networks": { "model_provider_private": null }, "ports": [ { "mode": "host", - "target": 80, + "target": 4000, "protocol": "tcp" } ] diff --git a/src/testdata/provider/compose.yaml.fixup b/src/testdata/provider/compose.yaml.fixup index d986feaf2..1b5c9a647 100644 --- a/src/testdata/provider/compose.yaml.fixup +++ b/src/testdata/provider/compose.yaml.fixup @@ -1,19 +1,23 @@ { "ai_runner": { - "command": null, + "command": [ + "--drop_params", + "--model", + "ai/smollm2" + ], "entrypoint": null, "environment": { "DEBUG": "true", - "OPENAI_API_KEY": "" + "LITELLM_MASTER_KEY": "" }, - "image": "defangio/openai-access-gateway", + "image": "litellm/litellm:latest", "networks": { "model_provider_private": null }, "ports": [ { "mode": "host", - "target": 80, + "target": 4000, "protocol": "tcp" } ] From 3ebf282621df5a34a7b7ba0e04855dc74c748921 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Mon, 6 Apr 2026 10:32:22 -0700 Subject: [PATCH 13/22] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lio李歐 Co-authored-by: Jordan Stephens --- src/pkg/cli/compose/fixup.go | 4 ++-- src/testdata/llm/compose.yaml | 2 +- src/testdata/llm/compose.yaml.fixup | 9 +-------- src/testdata/llm/compose.yaml.golden | 2 +- 4 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/pkg/cli/compose/fixup.go b/src/pkg/cli/compose/fixup.go index 0953e08b3..63cebdae8 100644 --- a/src/pkg/cli/compose/fixup.go +++ b/src/pkg/cli/compose/fixup.go @@ -370,10 +370,10 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo svccfg.Environment = composeTypes.MappingWithEquals{} } if _, exists := svccfg.Environment["LITELLM_MASTER_KEY"]; !exists { - svccfg.Environment["LITELLM_MASTER_KEY"] = &empty // disable auth; see https://github.com/DefangLabs/openai-access-gateway/pull/5 + svccfg.Environment["LITELLM_MASTER_KEY"] = &empty } // svccfg.HealthCheck = &composeTypes.ServiceHealthCheckConfig{} TODO: add healthcheck - svccfg.Image = "litellm/litellm:latest" + svccfg.Image = "litellm/litellm:v1.82.3-stable.patch.3" svccfg.Command = []string{"--drop_params", "--model", model} if svccfg.Networks == nil { // New compose-go versions do not create networks for "provider:" services, so we need to create it here diff --git a/src/testdata/llm/compose.yaml b/src/testdata/llm/compose.yaml index d8501357f..af44de90a 100644 --- a/src/testdata/llm/compose.yaml +++ b/src/testdata/llm/compose.yaml @@ -1,7 +1,7 @@ services: llm: x-defang-llm: true - image: "litellm/litellm:latest" + image: "llm:latest" gateway-with-ports: x-defang-llm: true diff --git a/src/testdata/llm/compose.yaml.fixup b/src/testdata/llm/compose.yaml.fixup index 3dccd64ac..9e4806afc 100644 --- a/src/testdata/llm/compose.yaml.fixup +++ b/src/testdata/llm/compose.yaml.fixup @@ -37,13 +37,6 @@ "image": "litellm/litellm:latest", "networks": { "default": null - }, - "ports": [ - { - "mode": "host", - "target": 4000, - "protocol": "tcp" - } - ] + } } } \ No newline at end of file diff --git a/src/testdata/llm/compose.yaml.golden b/src/testdata/llm/compose.yaml.golden index 07844da8b..00b98f598 100644 --- a/src/testdata/llm/compose.yaml.golden +++ b/src/testdata/llm/compose.yaml.golden @@ -16,7 +16,7 @@ services: default: null x-defang-llm: true llm: - image: litellm/litellm:latest + image: llm:latest networks: default: null x-defang-llm: true From 352df39837a0c85d3835a3755c52bf359e7ac112 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 6 Apr 2026 10:47:53 -0700 Subject: [PATCH 14/22] restore original test cases --- src/pkg/cli/compose/fixup.go | 4 ++-- src/testdata/llm/compose.yaml | 9 +++++++++ src/testdata/llm/compose.yaml.fixup | 17 ++++++++++++++++- src/testdata/llm/compose.yaml.golden | 9 +++++++++ src/testdata/llm/compose.yaml.warnings | 1 + src/testdata/models/compose.yaml.fixup | 4 ++-- src/testdata/provider/compose.yaml.fixup | 2 +- 7 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/pkg/cli/compose/fixup.go b/src/pkg/cli/compose/fixup.go index 63cebdae8..ea7b0a5f0 100644 --- a/src/pkg/cli/compose/fixup.go +++ b/src/pkg/cli/compose/fixup.go @@ -370,10 +370,10 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo svccfg.Environment = composeTypes.MappingWithEquals{} } if _, exists := svccfg.Environment["LITELLM_MASTER_KEY"]; !exists { - svccfg.Environment["LITELLM_MASTER_KEY"] = &empty + svccfg.Environment["LITELLM_MASTER_KEY"] = &empty } // svccfg.HealthCheck = &composeTypes.ServiceHealthCheckConfig{} TODO: add healthcheck - svccfg.Image = "litellm/litellm:v1.82.3-stable.patch.3" + svccfg.Image = "litellm/litellm:v1.82.3-stable.patch.3" svccfg.Command = []string{"--drop_params", "--model", model} if svccfg.Networks == nil { // New compose-go versions do not create networks for "provider:" services, so we need to create it here diff --git a/src/testdata/llm/compose.yaml b/src/testdata/llm/compose.yaml index af44de90a..2dd80073a 100644 --- a/src/testdata/llm/compose.yaml +++ b/src/testdata/llm/compose.yaml @@ -1,4 +1,13 @@ services: + alt-repo: + x-defang-llm: true + image: "altrepo.com/litellm:latest" + networks: + default: null + ports: + - mode: host + target: "4000" + protocol: tcp llm: x-defang-llm: true image: "llm:latest" diff --git a/src/testdata/llm/compose.yaml.fixup b/src/testdata/llm/compose.yaml.fixup index 9e4806afc..0978b0653 100644 --- a/src/testdata/llm/compose.yaml.fixup +++ b/src/testdata/llm/compose.yaml.fixup @@ -1,4 +1,19 @@ { + "alt-repo": { + "command": null, + "entrypoint": null, + "image": "altrepo.com/litellm:latest", + "networks": { + "default": null + }, + "ports": [ + { + "mode": "host", + "target": 4000, + "protocol": "tcp" + } + ] + }, "gateway-with-ports": { "command": null, "entrypoint": null, @@ -34,7 +49,7 @@ "llm": { "command": null, "entrypoint": null, - "image": "litellm/litellm:latest", + "image": "llm:latest", "networks": { "default": null } diff --git a/src/testdata/llm/compose.yaml.golden b/src/testdata/llm/compose.yaml.golden index 00b98f598..d6f462384 100644 --- a/src/testdata/llm/compose.yaml.golden +++ b/src/testdata/llm/compose.yaml.golden @@ -1,5 +1,14 @@ name: llm services: + alt-repo: + image: altrepo.com/litellm:latest + networks: + default: null + ports: + - mode: host + target: 4000 + protocol: tcp + x-defang-llm: true gateway-with-ports: image: litellm/litellm:latest networks: diff --git a/src/testdata/llm/compose.yaml.warnings b/src/testdata/llm/compose.yaml.warnings index 1f7003885..d6021d45a 100644 --- a/src/testdata/llm/compose.yaml.warnings +++ b/src/testdata/llm/compose.yaml.warnings @@ -1,3 +1,4 @@ + ! service "alt-repo": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors ! service "gateway-with-ports": ingress port 5678 without healthcheck; defaults to GET / HTTP/1.1 ! service "gateway-with-ports": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors ! service "gateway-without-ports": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors diff --git a/src/testdata/models/compose.yaml.fixup b/src/testdata/models/compose.yaml.fixup index d3ef2ac18..329a53581 100644 --- a/src/testdata/models/compose.yaml.fixup +++ b/src/testdata/models/compose.yaml.fixup @@ -9,7 +9,7 @@ "environment": { "LITELLM_MASTER_KEY": "" }, - "image": "litellm/litellm:latest", + "image": "litellm/litellm:v1.82.3-stable.patch.3", "networks": { "model_provider_private": null }, @@ -63,7 +63,7 @@ "environment": { "LITELLM_MASTER_KEY": "" }, - "image": "litellm/litellm:latest", + "image": "litellm/litellm:v1.82.3-stable.patch.3", "networks": { "model_provider_private": null }, diff --git a/src/testdata/provider/compose.yaml.fixup b/src/testdata/provider/compose.yaml.fixup index 1b5c9a647..c7fd5ef3a 100644 --- a/src/testdata/provider/compose.yaml.fixup +++ b/src/testdata/provider/compose.yaml.fixup @@ -10,7 +10,7 @@ "DEBUG": "true", "LITELLM_MASTER_KEY": "" }, - "image": "litellm/litellm:latest", + "image": "litellm/litellm:v1.82.3-stable.patch.3", "networks": { "model_provider_private": null }, From e8998eb474fa31038f508149e336d2848713ee70 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 6 Apr 2026 10:53:07 -0700 Subject: [PATCH 15/22] use a named constant for litellm port --- src/pkg/cli/compose/fixup.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pkg/cli/compose/fixup.go b/src/pkg/cli/compose/fixup.go index ea7b0a5f0..0446831f9 100644 --- a/src/pkg/cli/compose/fixup.go +++ b/src/pkg/cli/compose/fixup.go @@ -227,12 +227,14 @@ func parsePortString(port string) (uint32, error) { } } +const liteLLMPort uint32 = 4000 + func fixupLLM(svccfg *composeTypes.ServiceConfig) { image := GetImageRepo(svccfg.Image) if strings.HasSuffix(image, "/litellm") && len(svccfg.Ports) == 0 { // HACK: we must have at least one host port to get a CNAME for the service // litellm listens on 4000 by default - var port uint32 = 4000 + var port uint32 = liteLLMPort term.Debugf("service %q: adding LLM host port %d", svccfg.Name, port) svccfg.Ports = []composeTypes.ServicePortConfig{{Target: port, Mode: Mode_HOST, Protocol: Protocol_TCP}} } @@ -382,7 +384,7 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo delete(svccfg.Networks, "default") // remove the default network } svccfg.Networks[modelProviderNetwork] = nil - svccfg.Ports = []composeTypes.ServicePortConfig{{Target: 4000, Mode: Mode_HOST, Protocol: Protocol_TCP}} + svccfg.Ports = []composeTypes.ServicePortConfig{{Target: liteLLMPort, Mode: Mode_HOST, Protocol: Protocol_TCP}} svccfg.Provider = nil // remove "provider:" because current backend will not accept it project.Networks[modelProviderNetwork] = composeTypes.NetworkConfig{Name: modelProviderNetwork} From 854f0eddb4c821385793a3d4e6134c412a680973 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Tue, 7 Apr 2026 16:25:00 -0700 Subject: [PATCH 16/22] llm service url should have port --- src/pkg/cli/compose/fixup.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pkg/cli/compose/fixup.go b/src/pkg/cli/compose/fixup.go index 0446831f9..230375f6a 100644 --- a/src/pkg/cli/compose/fixup.go +++ b/src/pkg/cli/compose/fixup.go @@ -363,7 +363,7 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo // Local Docker sets [SERVICE]_URL and [SERVICE]_MODEL environment variables on the dependent services envName := strings.ToUpper(svccfg.Name) // TODO: handle characters that are not allowed in env vars, like '-' endpointEnvVar := envName + "_URL" - urlVal := "http://" + svccfg.Name + "/api/v1/" + urlVal := "http://" + svccfg.Name + ":" + strconv.FormatUint(uint64(liteLLMPort), 10) + "/api/v1/" modelEnvVar := envName + "_MODEL" empty := "" From 092c1227d4e83632a329f4df0abfa9c4b09e69fd Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Wed, 8 Apr 2026 10:48:18 -0700 Subject: [PATCH 17/22] configure LITELLM_MASTER_KEY --- src/pkg/cli/compose/fixup.go | 33 +++++++++++++++++---- src/testdata/models/compose.yaml.warnings | 1 + src/testdata/provider/compose.yaml.warnings | 1 + 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/pkg/cli/compose/fixup.go b/src/pkg/cli/compose/fixup.go index 230375f6a..6eb811d24 100644 --- a/src/pkg/cli/compose/fixup.go +++ b/src/pkg/cli/compose/fixup.go @@ -363,17 +363,13 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo // Local Docker sets [SERVICE]_URL and [SERVICE]_MODEL environment variables on the dependent services envName := strings.ToUpper(svccfg.Name) // TODO: handle characters that are not allowed in env vars, like '-' endpointEnvVar := envName + "_URL" - urlVal := "http://" + svccfg.Name + ":" + strconv.FormatUint(uint64(liteLLMPort), 10) + "/api/v1/" + urlVal := "http://" + svccfg.Name + ":" + strconv.FormatUint(uint64(liteLLMPort), 10) + "/v1/" modelEnvVar := envName + "_MODEL" - empty := "" // svccfg.Deploy.Resources.Reservations.Limits = &composeTypes.Resources{} TODO: avoid memory limits warning if svccfg.Environment == nil { svccfg.Environment = composeTypes.MappingWithEquals{} } - if _, exists := svccfg.Environment["LITELLM_MASTER_KEY"]; !exists { - svccfg.Environment["LITELLM_MASTER_KEY"] = &empty - } // svccfg.HealthCheck = &composeTypes.ServiceHealthCheckConfig{} TODO: add healthcheck svccfg.Image = "litellm/litellm:v1.82.3-stable.patch.3" svccfg.Command = []string{"--drop_params", "--model", model} @@ -388,6 +384,30 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo svccfg.Provider = nil // remove "provider:" because current backend will not accept it project.Networks[modelProviderNetwork] = composeTypes.NetworkConfig{Name: modelProviderNetwork} + liteLLMMasterKey, exists := svccfg.Environment["LITELLM_MASTER_KEY"] + if !exists { + openAIKey := "" + for _, service := range project.Services { + if _, ok := service.DependsOn[svccfg.Name]; ok { + if key, ok := service.Environment["OPENAI_API_KEY"]; ok { + if openAIKey == "" { + openAIKey = *key + } else if *key != openAIKey { + term.Errorf("multiple different OPENAI_API_KEY values found in services depending on %q", svccfg.Name) + break + } + } + } + } + if openAIKey == "" { + key := "networkisalreadyprivate" + liteLLMMasterKey = &key + } else { + liteLLMMasterKey = &openAIKey + } + svccfg.Environment["LITELLM_MASTER_KEY"] = liteLLMMasterKey + } + // Set environment variables (url and model) for any service that depends on the model for _, dependency := range project.Services { if _, ok := dependency.DependsOn[svccfg.Name]; ok { @@ -401,6 +421,9 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo if _, ok := dependency.Environment[modelEnvVar]; !ok && model != "" { dependency.Environment[modelEnvVar] = &model } + if _, ok := dependency.Environment["OPENAI_API_KEY"]; !ok { + dependency.Environment["OPENAI_API_KEY"] = liteLLMMasterKey + } } if modelDep, ok := dependency.Models[svccfg.Name]; ok { diff --git a/src/testdata/models/compose.yaml.warnings b/src/testdata/models/compose.yaml.warnings index c514b394f..9d5a96c1a 100644 --- a/src/testdata/models/compose.yaml.warnings +++ b/src/testdata/models/compose.yaml.warnings @@ -1,3 +1,4 @@ + ! service "ai_model": environment "LITELLM_MASTER_KEY" may contain sensitive information; consider using 'defang config set LITELLM_MASTER_KEY' to securely store this value ! service "ai_model": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors ! service "modellist": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors ! service "modelmap": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors diff --git a/src/testdata/provider/compose.yaml.warnings b/src/testdata/provider/compose.yaml.warnings index 3a7067aa2..4107adade 100644 --- a/src/testdata/provider/compose.yaml.warnings +++ b/src/testdata/provider/compose.yaml.warnings @@ -1,2 +1,3 @@ + ! service "ai_runner": environment "LITELLM_MASTER_KEY" may contain sensitive information; consider using 'defang config set LITELLM_MASTER_KEY' to securely store this value ! service "ai_runner": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors ! service "chat": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors From 7949f7e897c274f003e1b2c33eaa617964af49ed Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Wed, 8 Apr 2026 11:31:24 -0700 Subject: [PATCH 18/22] configure litellm per provider --- src/pkg/cli/client/mock.go | 4 + src/pkg/cli/compose/fixup.go | 60 +++++++-- src/pkg/cli/compose/fixup_test.go | 127 ++++++++++++++++++++ src/testdata/models/compose.yaml.fixup | 14 ++- src/testdata/models/compose.yaml.warnings | 1 + src/testdata/provider/compose.yaml.fixup | 7 +- src/testdata/provider/compose.yaml.warnings | 1 + 7 files changed, 199 insertions(+), 15 deletions(-) diff --git a/src/pkg/cli/client/mock.go b/src/pkg/cli/client/mock.go index 272752155..048e1862f 100644 --- a/src/pkg/cli/client/mock.go +++ b/src/pkg/cli/client/mock.go @@ -63,6 +63,10 @@ func (MockProvider) UpdateShardDomain(ctx context.Context) error { return nil } +func (MockProvider) AccountInfo(context.Context) (*AccountInfo, error) { + return &AccountInfo{}, nil +} + func (MockProvider) GetStackName() string { return "test" } diff --git a/src/pkg/cli/compose/fixup.go b/src/pkg/cli/compose/fixup.go index 6eb811d24..1822d31e6 100644 --- a/src/pkg/cli/compose/fixup.go +++ b/src/pkg/cli/compose/fixup.go @@ -37,6 +37,12 @@ func FixupServices(ctx context.Context, provider client.Provider, project *compo } slices.Sort(config.Names) // sort for binary search + accountInfo, err := provider.AccountInfo(ctx) + if err != nil { + term.Debugf("failed to get account info to fixup services: %v", err) + accountInfo = &client.AccountInfo{} + } + // Fixup any pseudo services (this might create port configs, which will affect service name replacement by ReplaceServiceNameWithDNS) for _, svccfg := range project.Services { repo := GetImageRepo(svccfg.Image) @@ -63,7 +69,7 @@ func FixupServices(ctx context.Context, provider client.Provider, project *compo } if svccfg.Provider != nil && svccfg.Provider.Type == "model" && svccfg.Image == "" && svccfg.Build == nil { - fixupModelProvider(&svccfg, project) + fixupModelProvider(&svccfg, project, accountInfo) } if _, llm := svccfg.Extensions["x-defang-llm"]; llm { @@ -87,7 +93,7 @@ func FixupServices(ctx context.Context, provider client.Provider, project *compo for name, model := range project.Models { model.Name = name // ensure the model has a name - svccfg := fixupModel(model, project) + svccfg := fixupModel(model, project, accountInfo) project.Services[svccfg.Name] = *svccfg } @@ -342,24 +348,24 @@ func fixupIngressPorts(svccfg *composeTypes.ServiceConfig) { // Declare a private network for the model provider const modelProviderNetwork = "model_provider_private" -func fixupModel(model composeTypes.ModelConfig, project *composeTypes.Project) *composeTypes.ServiceConfig { +func fixupModel(model composeTypes.ModelConfig, project *composeTypes.Project, info *client.AccountInfo) *composeTypes.ServiceConfig { svccfg := &composeTypes.ServiceConfig{ Name: model.Name, Extensions: model.Extensions, } - makeAccessGatewayService(svccfg, project, model.Model) // TODO: pass other model options too + makeAccessGatewayService(svccfg, project, model.Model, info) // TODO: pass other model options too return svccfg } -func fixupModelProvider(svccfg *composeTypes.ServiceConfig, project *composeTypes.Project) { +func fixupModelProvider(svccfg *composeTypes.ServiceConfig, project *composeTypes.Project, info *client.AccountInfo) { var model string if modelVals := svccfg.Provider.Options["model"]; len(modelVals) == 1 { model = modelVals[0] } - makeAccessGatewayService(svccfg, project, model) + makeAccessGatewayService(svccfg, project, model, info) } -func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *composeTypes.Project, model string) { +func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *composeTypes.Project, model string, info *client.AccountInfo) { // Local Docker sets [SERVICE]_URL and [SERVICE]_MODEL environment variables on the dependent services envName := strings.ToUpper(svccfg.Name) // TODO: handle characters that are not allowed in env vars, like '-' endpointEnvVar := envName + "_URL" @@ -370,9 +376,40 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo if svccfg.Environment == nil { svccfg.Environment = composeTypes.MappingWithEquals{} } + + alias := model + switch info.Provider { + case client.ProviderAWS: + switch model { + case "chat-default": + model = "us.amazon.nova-2-lite-v1:0" + case "embedding-default": + model = "amazon.titan-embed-text-v2:0" + } + model = modelWithProvider(model, "bedrock") + if info.Region != "" { + svccfg.Environment["AWS_REGION"] = &info.Region + } + case client.ProviderGCP: + switch model { + case "chat-default": + model = "gemini-2.5-flash" + case "embedding-default": + model = "gemini-embedding-001" + } + model = modelWithProvider(model, "vertex_ai") + if info.AccountID != "" { + svccfg.Environment["VERTEXAI_PROJECT"] = &info.AccountID + } + if info.Region != "" { + svccfg.Environment["VERTEXAI_LOCATION"] = &info.Region + } + } + // svccfg.HealthCheck = &composeTypes.ServiceHealthCheckConfig{} TODO: add healthcheck svccfg.Image = "litellm/litellm:v1.82.3-stable.patch.3" - svccfg.Command = []string{"--drop_params", "--model", model} + command := []string{"--drop_params", "--model", model, "--alias", alias} + svccfg.Command = command if svccfg.Networks == nil { // New compose-go versions do not create networks for "provider:" services, so we need to create it here svccfg.Networks = make(map[string]*composeTypes.ServiceNetworkConfig) @@ -459,6 +496,13 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo } } +func modelWithProvider(model, prefix string) string { + if strings.Contains(model, "/") { + return model // already has a provider prefix + } + return prefix + "/" + model +} + func GetImageRepo(image string) string { repo, _, _ := strings.Cut(image, ":") return strings.ToLower(repo) diff --git a/src/pkg/cli/compose/fixup_test.go b/src/pkg/cli/compose/fixup_test.go index 89ca089f2..5db21cd74 100644 --- a/src/pkg/cli/compose/fixup_test.go +++ b/src/pkg/cli/compose/fixup_test.go @@ -9,6 +9,7 @@ import ( "github.com/DefangLabs/defang/src/pkg/cli/client" composeTypes "github.com/compose-spec/compose-go/v2/types" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestFixup(t *testing.T) { @@ -43,3 +44,129 @@ func TestFixup(t *testing.T) { } }) } + +func newLLMService() composeTypes.ServiceConfig { + return composeTypes.ServiceConfig{ + Name: "llm", + Environment: composeTypes.MappingWithEquals{}, + Networks: map[string]*composeTypes.ServiceNetworkConfig{}, + } +} + +func TestMakeAccessGatewayServiceAWS(t *testing.T) { + info := &client.AccountInfo{ + Provider: client.ProviderAWS, + Region: "us-east-1", + AccountID: "123456789", + } + + t.Run("chat-default model", func(t *testing.T) { + proj := &composeTypes.Project{Networks: map[string]composeTypes.NetworkConfig{}, Services: composeTypes.Services{}} + svccfg := newLLMService() + makeAccessGatewayService(&svccfg, proj, "chat-default", info) + + require.Equal(t, []string{"--drop_params", "--model", "bedrock/us.amazon.nova-2-lite-v1:0", "--alias", "chat-default"}, []string(svccfg.Command)) + assert.Equal(t, "us-east-1", *svccfg.Environment["AWS_REGION"]) + }) + + t.Run("embedding-default model", func(t *testing.T) { + proj := &composeTypes.Project{Networks: map[string]composeTypes.NetworkConfig{}, Services: composeTypes.Services{}} + svccfg := newLLMService() + makeAccessGatewayService(&svccfg, proj, "embedding-default", info) + + require.Equal(t, []string{"--drop_params", "--model", "bedrock/amazon.titan-embed-text-v2:0", "--alias", "embedding-default"}, []string(svccfg.Command)) + }) + + t.Run("custom model gets bedrock prefix", func(t *testing.T) { + proj := &composeTypes.Project{Networks: map[string]composeTypes.NetworkConfig{}, Services: composeTypes.Services{}} + svccfg := newLLMService() + makeAccessGatewayService(&svccfg, proj, "anthropic.claude-3-5-sonnet", info) + + require.Equal(t, []string{"--drop_params", "--model", "bedrock/anthropic.claude-3-5-sonnet", "--alias", "anthropic.claude-3-5-sonnet"}, []string(svccfg.Command)) + }) + + t.Run("model with existing provider prefix is not double-prefixed", func(t *testing.T) { + proj := &composeTypes.Project{Networks: map[string]composeTypes.NetworkConfig{}, Services: composeTypes.Services{}} + svccfg := newLLMService() + makeAccessGatewayService(&svccfg, proj, "bedrock/anthropic.claude-3-5-sonnet", info) + + require.Equal(t, []string{"--drop_params", "--model", "bedrock/anthropic.claude-3-5-sonnet", "--alias", "bedrock/anthropic.claude-3-5-sonnet"}, []string(svccfg.Command)) + }) +} + +func TestMakeAccessGatewayServiceGCP(t *testing.T) { + info := &client.AccountInfo{ + Provider: client.ProviderGCP, + Region: "us-central1", + AccountID: "my-gcp-project", + } + + t.Run("chat-default model", func(t *testing.T) { + proj := &composeTypes.Project{Networks: map[string]composeTypes.NetworkConfig{}, Services: composeTypes.Services{}} + svccfg := newLLMService() + makeAccessGatewayService(&svccfg, proj, "chat-default", info) + + require.Equal(t, []string{"--drop_params", "--model", "vertex_ai/gemini-2.5-flash", "--alias", "chat-default"}, []string(svccfg.Command)) + assert.Equal(t, "my-gcp-project", *svccfg.Environment["VERTEXAI_PROJECT"]) + assert.Equal(t, "us-central1", *svccfg.Environment["VERTEXAI_LOCATION"]) + }) + + t.Run("embedding-default model", func(t *testing.T) { + proj := &composeTypes.Project{Networks: map[string]composeTypes.NetworkConfig{}, Services: composeTypes.Services{}} + svccfg := newLLMService() + makeAccessGatewayService(&svccfg, proj, "embedding-default", info) + + require.Equal(t, []string{"--drop_params", "--model", "vertex_ai/gemini-embedding-001", "--alias", "embedding-default"}, []string(svccfg.Command)) + }) +} + +func TestMakeAccessGatewayServiceLiteLLMMasterKey(t *testing.T) { + info := &client.AccountInfo{} + + t.Run("no OPENAI_API_KEY uses default key", func(t *testing.T) { + proj := &composeTypes.Project{Networks: map[string]composeTypes.NetworkConfig{}, Services: composeTypes.Services{}} + svccfg := newLLMService() + makeAccessGatewayService(&svccfg, proj, "ai/model", info) + + assert.Equal(t, "networkisalreadyprivate", *svccfg.Environment["LITELLM_MASTER_KEY"]) + }) + + t.Run("OPENAI_API_KEY on dependent service propagates to LITELLM_MASTER_KEY", func(t *testing.T) { + apiKey := "sk-my-secret-key" //nolint:gosec + proj := &composeTypes.Project{ + Networks: map[string]composeTypes.NetworkConfig{}, + Services: composeTypes.Services{ + "app": { + Name: "app", + Image: "myapp", + DependsOn: map[string]composeTypes.ServiceDependency{ + "llm": {Condition: composeTypes.ServiceConditionStarted, Required: true}, + }, + Environment: composeTypes.MappingWithEquals{"OPENAI_API_KEY": &apiKey}, + Networks: map[string]*composeTypes.ServiceNetworkConfig{}, + }, + }, + } + svccfg := newLLMService() + makeAccessGatewayService(&svccfg, proj, "ai/model", info) + + assert.Equal(t, "sk-my-secret-key", *svccfg.Environment["LITELLM_MASTER_KEY"]) + }) + + t.Run("existing LITELLM_MASTER_KEY on service is preserved", func(t *testing.T) { + proj := &composeTypes.Project{Networks: map[string]composeTypes.NetworkConfig{}, Services: composeTypes.Services{}} + existingKey := "existing-master-key" + svccfg := newLLMService() + svccfg.Environment["LITELLM_MASTER_KEY"] = &existingKey + makeAccessGatewayService(&svccfg, proj, "ai/model", info) + + assert.Equal(t, "existing-master-key", *svccfg.Environment["LITELLM_MASTER_KEY"]) + }) +} + +func TestModelWithProvider(t *testing.T) { + assert.Equal(t, "bedrock/my-model", modelWithProvider("my-model", "bedrock")) + assert.Equal(t, "bedrock/my-model", modelWithProvider("bedrock/my-model", "bedrock")) + assert.Equal(t, "vertex_ai/gemini-2.5-flash", modelWithProvider("gemini-2.5-flash", "vertex_ai")) + assert.Equal(t, "vertex_ai/gemini-2.5-flash", modelWithProvider("vertex_ai/gemini-2.5-flash", "vertex_ai")) +} diff --git a/src/testdata/models/compose.yaml.fixup b/src/testdata/models/compose.yaml.fixup index 329a53581..27851b366 100644 --- a/src/testdata/models/compose.yaml.fixup +++ b/src/testdata/models/compose.yaml.fixup @@ -3,11 +3,13 @@ "command": [ "--drop_params", "--model", + "ai/model", + "--alias", "ai/model" ], "entrypoint": null, "environment": { - "LITELLM_MASTER_KEY": "" + "LITELLM_MASTER_KEY": "networkisalreadyprivate" }, "image": "litellm/litellm:v1.82.3-stable.patch.3", "networks": { @@ -26,7 +28,7 @@ "entrypoint": null, "environment": { "AI_MODEL_MODEL": "ai/model", - "AI_MODEL_URL": "http://mock-ai-model/api/v1/" + "AI_MODEL_URL": "http://mock-ai-model:4000/v1/" }, "image": "app", "models": { @@ -42,7 +44,7 @@ "entrypoint": null, "environment": { "AI_MODEL_MODEL": "ai/model", - "AI_MODEL_URL": "http://mock-ai-model/api/v1/" + "AI_MODEL_URL": "http://mock-ai-model:4000/v1/" }, "image": "app", "models": { @@ -57,11 +59,13 @@ "command": [ "--drop_params", "--model", + "ai/model", + "--alias", "ai/model" ], "entrypoint": null, "environment": { - "LITELLM_MASTER_KEY": "" + "LITELLM_MASTER_KEY": "networkisalreadyprivate" }, "image": "litellm/litellm:v1.82.3-stable.patch.3", "networks": { @@ -79,7 +83,7 @@ "command": null, "entrypoint": null, "environment": { - "MODEL_URL": "http://mock-my-model/api/v1/", + "MODEL_URL": "http://mock-my-model:4000/v1/", "MY_MODEL_MODEL": "ai/model" }, "image": "app", diff --git a/src/testdata/models/compose.yaml.warnings b/src/testdata/models/compose.yaml.warnings index 9d5a96c1a..83f7405d9 100644 --- a/src/testdata/models/compose.yaml.warnings +++ b/src/testdata/models/compose.yaml.warnings @@ -2,5 +2,6 @@ ! service "ai_model": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors ! service "modellist": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors ! service "modelmap": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors + ! service "my_model": environment "LITELLM_MASTER_KEY" may contain sensitive information; consider using 'defang config set LITELLM_MASTER_KEY' to securely store this value ! service "my_model": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors ! service "withendpoint": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors diff --git a/src/testdata/provider/compose.yaml.fixup b/src/testdata/provider/compose.yaml.fixup index c7fd5ef3a..63cdc7081 100644 --- a/src/testdata/provider/compose.yaml.fixup +++ b/src/testdata/provider/compose.yaml.fixup @@ -3,12 +3,14 @@ "command": [ "--drop_params", "--model", + "ai/smollm2", + "--alias", "ai/smollm2" ], "entrypoint": null, "environment": { "DEBUG": "true", - "LITELLM_MASTER_KEY": "" + "LITELLM_MASTER_KEY": "networkisalreadyprivate" }, "image": "litellm/litellm:v1.82.3-stable.patch.3", "networks": { @@ -33,7 +35,8 @@ "entrypoint": null, "environment": { "AI_RUNNER_MODEL": "ai/smollm2", - "AI_RUNNER_URL": "http://mock-ai-runner/api/v1/" + "AI_RUNNER_URL": "http://mock-ai-runner:4000/v1/", + "OPENAI_API_KEY": "networkisalreadyprivate" }, "image": "my-chat-app", "networks": { diff --git a/src/testdata/provider/compose.yaml.warnings b/src/testdata/provider/compose.yaml.warnings index 4107adade..95bc4b9ba 100644 --- a/src/testdata/provider/compose.yaml.warnings +++ b/src/testdata/provider/compose.yaml.warnings @@ -1,3 +1,4 @@ ! service "ai_runner": environment "LITELLM_MASTER_KEY" may contain sensitive information; consider using 'defang config set LITELLM_MASTER_KEY' to securely store this value ! service "ai_runner": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors + ! service "chat": environment "OPENAI_API_KEY" may contain sensitive information; consider using 'defang config set OPENAI_API_KEY' to securely store this value ! service "chat": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors From 7c434ade349b71e7fdd184f8971a74ba91f82ac5 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Wed, 8 Apr 2026 17:34:59 -0700 Subject: [PATCH 19/22] fix: write back service after mutating DependsOn in models loop Range-iterating project.Services yields value copies, so assigning a newly created DependsOn map to the local copy was silently dropped. Switch to keyed iteration and write the service back whenever it was changed. Co-Authored-By: Claude Sonnet 4.6 --- src/pkg/cli/compose/fixup.go | 10 +++++++++- src/testdata/models/compose.yaml.fixup | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/pkg/cli/compose/fixup.go b/src/pkg/cli/compose/fixup.go index 1822d31e6..35bc27e1f 100644 --- a/src/pkg/cli/compose/fixup.go +++ b/src/pkg/cli/compose/fixup.go @@ -446,7 +446,9 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo } // Set environment variables (url and model) for any service that depends on the model - for _, dependency := range project.Services { + for name, dependency := range project.Services { + changed := false + if _, ok := dependency.DependsOn[svccfg.Name]; ok { if dependency.Environment == nil { dependency.Environment = make(composeTypes.MappingWithEquals) @@ -461,6 +463,7 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo if _, ok := dependency.Environment["OPENAI_API_KEY"]; !ok { dependency.Environment["OPENAI_API_KEY"] = liteLLMMasterKey } + changed = true } if modelDep, ok := dependency.Models[svccfg.Name]; ok { @@ -492,6 +495,11 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo Required: true, } } + changed = true + } + + if changed { + project.Services[name] = dependency } } } diff --git a/src/testdata/models/compose.yaml.fixup b/src/testdata/models/compose.yaml.fixup index 27851b366..151cb4df0 100644 --- a/src/testdata/models/compose.yaml.fixup +++ b/src/testdata/models/compose.yaml.fixup @@ -25,6 +25,12 @@ }, "modellist": { "command": null, + "depends_on": { + "ai_model": { + "condition": "service_started", + "required": true + } + }, "entrypoint": null, "environment": { "AI_MODEL_MODEL": "ai/model", @@ -41,6 +47,12 @@ }, "modelmap": { "command": null, + "depends_on": { + "ai_model": { + "condition": "service_started", + "required": true + } + }, "entrypoint": null, "environment": { "AI_MODEL_MODEL": "ai/model", @@ -81,6 +93,12 @@ }, "withendpoint": { "command": null, + "depends_on": { + "my_model": { + "condition": "service_started", + "required": true + } + }, "entrypoint": null, "environment": { "MODEL_URL": "http://mock-my-model:4000/v1/", From bbb8b37fab3a49beceb9bdcffc6dccfd34f72b02 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Wed, 8 Apr 2026 17:36:23 -0700 Subject: [PATCH 20/22] refactor: extract configureAccessGateway and wireDependentServices Split makeAccessGatewayService into two focused helpers: - configureAccessGateway: resolves the model for the target cloud provider, sets up the LiteLLM container (image, command, network, port), and derives LITELLM_MASTER_KEY - wireDependentServices: injects URL/model/key env vars and network membership into every service that depends on the model service Co-Authored-By: Claude Sonnet 4.6 --- src/pkg/cli/compose/fixup.go | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/pkg/cli/compose/fixup.go b/src/pkg/cli/compose/fixup.go index 35bc27e1f..07a56b4eb 100644 --- a/src/pkg/cli/compose/fixup.go +++ b/src/pkg/cli/compose/fixup.go @@ -372,6 +372,14 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo urlVal := "http://" + svccfg.Name + ":" + strconv.FormatUint(uint64(liteLLMPort), 10) + "/v1/" modelEnvVar := envName + "_MODEL" + resolvedModel, masterKey := configureAccessGateway(svccfg, project, model, info) + wireDependentServices(project, svccfg.Name, urlVal, resolvedModel, masterKey, endpointEnvVar, modelEnvVar) +} + +// configureAccessGateway resolves the model name for the target provider, configures +// the LiteLLM container (image, command, network, port), and derives LITELLM_MASTER_KEY. +// Returns the resolved model string and the master key pointer. +func configureAccessGateway(svccfg *composeTypes.ServiceConfig, project *composeTypes.Project, model string, info *client.AccountInfo) (string, *string) { // svccfg.Deploy.Resources.Reservations.Limits = &composeTypes.Resources{} TODO: avoid memory limits warning if svccfg.Environment == nil { svccfg.Environment = composeTypes.MappingWithEquals{} @@ -408,8 +416,7 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo // svccfg.HealthCheck = &composeTypes.ServiceHealthCheckConfig{} TODO: add healthcheck svccfg.Image = "litellm/litellm:v1.82.3-stable.patch.3" - command := []string{"--drop_params", "--model", model, "--alias", alias} - svccfg.Command = command + svccfg.Command = []string{"--drop_params", "--model", model, "--alias", alias} if svccfg.Networks == nil { // New compose-go versions do not create networks for "provider:" services, so we need to create it here svccfg.Networks = make(map[string]*composeTypes.ServiceNetworkConfig) @@ -421,7 +428,7 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo svccfg.Provider = nil // remove "provider:" because current backend will not accept it project.Networks[modelProviderNetwork] = composeTypes.NetworkConfig{Name: modelProviderNetwork} - liteLLMMasterKey, exists := svccfg.Environment["LITELLM_MASTER_KEY"] + masterKey, exists := svccfg.Environment["LITELLM_MASTER_KEY"] if !exists { openAIKey := "" for _, service := range project.Services { @@ -438,18 +445,23 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo } if openAIKey == "" { key := "networkisalreadyprivate" - liteLLMMasterKey = &key + masterKey = &key } else { - liteLLMMasterKey = &openAIKey + masterKey = &openAIKey } - svccfg.Environment["LITELLM_MASTER_KEY"] = liteLLMMasterKey + svccfg.Environment["LITELLM_MASTER_KEY"] = masterKey } - // Set environment variables (url and model) for any service that depends on the model + return model, masterKey +} + +// wireDependentServices injects URL, model, and API-key env vars and adds the +// model-provider network to every service that depends on svcName. +func wireDependentServices(project *composeTypes.Project, svcName, urlVal, model string, masterKey *string, endpointEnvVar, modelEnvVar string) { for name, dependency := range project.Services { changed := false - if _, ok := dependency.DependsOn[svccfg.Name]; ok { + if _, ok := dependency.DependsOn[svcName]; ok { if dependency.Environment == nil { dependency.Environment = make(composeTypes.MappingWithEquals) } @@ -461,12 +473,12 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo dependency.Environment[modelEnvVar] = &model } if _, ok := dependency.Environment["OPENAI_API_KEY"]; !ok { - dependency.Environment["OPENAI_API_KEY"] = liteLLMMasterKey + dependency.Environment["OPENAI_API_KEY"] = masterKey } changed = true } - if modelDep, ok := dependency.Models[svccfg.Name]; ok { + if modelDep, ok := dependency.Models[svcName]; ok { endpointVar := endpointEnvVar if modelDep != nil && modelDep.EndpointVariable != "" { endpointVar = modelDep.EndpointVariable @@ -486,11 +498,11 @@ func makeAccessGatewayService(svccfg *composeTypes.ServiceConfig, project *compo dependency.Environment[modelVar] = &model } // If the model is not already declared as a dependency, add it - if _, ok := dependency.DependsOn[svccfg.Name]; !ok { + if _, ok := dependency.DependsOn[svcName]; !ok { if dependency.DependsOn == nil { dependency.DependsOn = make(map[string]composeTypes.ServiceDependency) } - dependency.DependsOn[svccfg.Name] = composeTypes.ServiceDependency{ + dependency.DependsOn[svcName] = composeTypes.ServiceDependency{ Condition: composeTypes.ServiceConditionStarted, Required: true, } From 9352b2317eccbd0934704524352739af4016b274 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Thu, 9 Apr 2026 09:45:02 -0700 Subject: [PATCH 21/22] update nix vendor hash --- 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 1c5b7cc9f..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-DxRBE7mugWJ2NqBiIDNazg/mb+zjZkgNjpTDJO/WZAY="; + vendorHash = "sha256-zxQuu/RcVgA67++LuRs5xpDiq2e7gepkV8nqQ2GCR74="; subPackages = [ "cmd/cli" ]; From b5714f03426d0d44fad647a776e32d6f7d657791 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Fri, 10 Apr 2026 13:42:21 -0700 Subject: [PATCH 22/22] fix: handle registry ports in fixupLLM image suffix check GetImageRepo used strings.Cut on the first ':' which dropped the image path when a registry included a port (e.g. registry.example:5000/litellm:latest). Replace with logic that strips only the tag/digest suffix after the last '/', checking '@' before ':' so digest refs (name@sha256:hex) are cut at the separator rather than inside the digest. Add TestFixupLLM covering registries with ports, digests, existing ports, and non-matching images. Co-Authored-By: Claude Sonnet 4.6 --- src/pkg/cli/compose/fixup.go | 15 ++++++- src/pkg/cli/compose/fixup_test.go | 67 +++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/pkg/cli/compose/fixup.go b/src/pkg/cli/compose/fixup.go index 07a56b4eb..615cae441 100644 --- a/src/pkg/cli/compose/fixup.go +++ b/src/pkg/cli/compose/fixup.go @@ -236,8 +236,19 @@ func parsePortString(port string) (uint32, error) { const liteLLMPort uint32 = 4000 func fixupLLM(svccfg *composeTypes.ServiceConfig) { - image := GetImageRepo(svccfg.Image) - if strings.HasSuffix(image, "/litellm") && len(svccfg.Ports) == 0 { + // Strip tag/digest: only remove the suffix after ':' or '@' if it appears after the last '/' + // so that a registry port (e.g. registry.example:5000/litellm:latest) is handled correctly. + // Check '@' before ':' so that digest refs (name@sha256:hex) are cut at the '@', not inside the hex. + sanitizedImage := svccfg.Image + lastSlash := strings.LastIndex(sanitizedImage, "/") + suffix := sanitizedImage[lastSlash+1:] + if i := strings.IndexByte(suffix, '@'); i >= 0 { + sanitizedImage = sanitizedImage[:lastSlash+1+i] + } else if i := strings.IndexByte(suffix, ':'); i >= 0 { + sanitizedImage = sanitizedImage[:lastSlash+1+i] + } + sanitizedImage = strings.ToLower(sanitizedImage) + if strings.HasSuffix(sanitizedImage, "/litellm") && len(svccfg.Ports) == 0 { // HACK: we must have at least one host port to get a CNAME for the service // litellm listens on 4000 by default var port uint32 = liteLLMPort diff --git a/src/pkg/cli/compose/fixup_test.go b/src/pkg/cli/compose/fixup_test.go index 5db21cd74..052a6a0b2 100644 --- a/src/pkg/cli/compose/fixup_test.go +++ b/src/pkg/cli/compose/fixup_test.go @@ -164,6 +164,73 @@ func TestMakeAccessGatewayServiceLiteLLMMasterKey(t *testing.T) { }) } +func TestFixupLLM(t *testing.T) { + tests := []struct { + name string + image string + existingPorts []composeTypes.ServicePortConfig + wantPort bool + }{ + { + name: "registry with port and tag adds litellm port", + image: "registry.example:5000/litellm:latest", + wantPort: true, + }, + { + name: "registry with port and no tag adds litellm port", + image: "registry.example:5000/litellm", + wantPort: true, + }, + { + name: "standard registry with path and tag adds litellm port", + image: "ghcr.io/berriai/litellm:main-latest", + wantPort: true, + }, + { + name: "image with digest adds litellm port", + image: "ghcr.io/berriai/litellm@sha256:abc123", + wantPort: true, + }, + { + name: "non-litellm image does not add port", + image: "registry.example:5000/other:tag", + wantPort: false, + }, + { + name: "litellm image with existing ports does not add port", + image: "ghcr.io/berriai/litellm:main-latest", + existingPorts: []composeTypes.ServicePortConfig{ + {Target: 8080, Mode: Mode_HOST, Protocol: Protocol_TCP}, + }, + wantPort: false, + }, + { + name: "bare image name without slash does not match", + image: "litellm:latest", + wantPort: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := composeTypes.ServiceConfig{ + Name: "llm", + Image: tt.image, + Ports: tt.existingPorts, + } + fixupLLM(&svc) + if tt.wantPort { + require.Len(t, svc.Ports, 1) + assert.Equal(t, liteLLMPort, svc.Ports[0].Target) + assert.Equal(t, Mode_HOST, svc.Ports[0].Mode) + assert.Equal(t, Protocol_TCP, svc.Ports[0].Protocol) + } else { + assert.Equal(t, tt.existingPorts, svc.Ports) + } + }) + } +} + func TestModelWithProvider(t *testing.T) { assert.Equal(t, "bedrock/my-model", modelWithProvider("my-model", "bedrock")) assert.Equal(t, "bedrock/my-model", modelWithProvider("bedrock/my-model", "bedrock"))