From d4ca1640af27e5ef6904ff7e6cfd8711909c2717 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Tue, 10 Mar 2026 14:26:25 +0100 Subject: [PATCH 1/2] feat(apps): support localOnly, value, and resolve fields in plugin manifest Add three new ResourceField properties to support manifest-driven .env prefilling for Lakebase: - localOnly: field only goes in .env/.env.example, skipped in app.yaml and databricks.yml (platform auto-injects at deploy time) - value: static constant fallback when no prompted/resolved value exists --- cmd/apps/init.go | 7 +- cmd/apps/init_test.go | 58 +++++++++-- libs/apps/generator/generator.go | 15 ++- libs/apps/generator/generator_test.go | 137 ++++++++++++++++++++++++++ libs/apps/manifest/manifest.go | 3 + libs/apps/prompt/listers.go | 11 +++ libs/apps/prompt/prompt.go | 58 ++++++++++- libs/apps/prompt/prompt_test.go | 107 ++++++++++++++++++++ 8 files changed, 380 insertions(+), 16 deletions(-) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index c7d5830637..95d4d4ce2b 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -227,7 +227,8 @@ func parseSetValues(setValues []string, m *manifest.Manifest) (map[string]string rv[resourceKey+"."+fieldName] = value } - // Validate multi-field resources: if any non-bundleIgnore field is set, all non-bundleIgnore fields must be set. + // Validate multi-field resources: if any user-provided field is set, all user-provided fields must be set. + // Fields with BundleIgnore or LocalOnly are auto-populated and exempt from this check. for _, p := range m.GetPlugins() { for _, r := range append(p.Resources.Required, p.Resources.Optional...) { if len(r.Fields) <= 1 { @@ -237,7 +238,7 @@ func parseSetValues(setValues []string, m *manifest.Manifest) (map[string]string setCount := 0 totalCheckable := 0 for _, fn := range names { - if r.Fields[fn].BundleIgnore { + if r.Fields[fn].BundleIgnore || r.Fields[fn].LocalOnly { continue } totalCheckable++ @@ -248,7 +249,7 @@ func parseSetValues(setValues []string, m *manifest.Manifest) (map[string]string if setCount > 0 && setCount < totalCheckable { var missing []string for _, fn := range names { - if r.Fields[fn].BundleIgnore { + if r.Fields[fn].BundleIgnore || r.Fields[fn].LocalOnly { continue } if rv[r.Key()+"."+fn] == "" { diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index 6b9e184c15..e563c2d645 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -512,9 +512,9 @@ func TestParseSetValuesBundleIgnoreSkipped(t *testing.T) { Alias: "Postgres", ResourceKey: "postgres", Fields: map[string]manifest.ResourceField{ - "branch": {Description: "branch path"}, - "database": {Description: "database name"}, - "endpoint": {Env: "LAKEBASE_ENDPOINT", BundleIgnore: true}, + "branch": {Description: "branch path"}, + "database": {Description: "database name"}, + "endpointPath": {Env: "LAKEBASE_ENDPOINT", BundleIgnore: true}, }, }, }, @@ -542,16 +542,60 @@ func TestParseSetValuesBundleIgnoreSkipped(t *testing.T) { rv, err = parseSetValues([]string{ "lakebase.postgres.branch=br", "lakebase.postgres.database=db", - "lakebase.postgres.endpoint=ep", + "lakebase.postgres.endpointPath=ep", }, m) require.NoError(t, err) assert.Equal(t, map[string]string{ - "postgres.branch": "br", - "postgres.database": "db", - "postgres.endpoint": "ep", + "postgres.branch": "br", + "postgres.database": "db", + "postgres.endpointPath": "ep", }, rv) } +func TestParseSetValuesLocalOnlySkipped(t *testing.T) { + m := &manifest.Manifest{ + Plugins: map[string]manifest.Plugin{ + "lakebase": { + Name: "lakebase", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + { + Type: "postgres", + Alias: "Postgres", + ResourceKey: "postgres", + Fields: map[string]manifest.ResourceField{ + "branch": {Description: "branch path"}, + "database": {Description: "database name"}, + "host": {Env: "PGHOST", LocalOnly: true, Resolve: "postgres:host"}, + "databaseName": {Env: "PGDATABASE", LocalOnly: true, Resolve: "postgres:databaseName"}, + "endpointPath": {Env: "LAKEBASE_ENDPOINT", BundleIgnore: true, Resolve: "postgres:endpointPath"}, + "port": {Env: "PGPORT", LocalOnly: true, Value: "5432"}, + "sslmode": {Env: "PGSSLMODE", LocalOnly: true, Value: "require"}, + }, + }, + }, + }, + }, + }, + } + + // Setting only branch+database should succeed — localOnly and bundleIgnore fields are exempt. + rv, err := parseSetValues([]string{ + "lakebase.postgres.branch=projects/p1/branches/main", + "lakebase.postgres.database=mydb", + }, m) + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "postgres.branch": "projects/p1/branches/main", + "postgres.database": "mydb", + }, rv) + + // Setting only branch should still fail (database is also required). + _, err = parseSetValues([]string{"lakebase.postgres.branch=br"}, m) + require.Error(t, err) + assert.Contains(t, err.Error(), `incomplete resource "postgres"`) +} + func TestPluginHasResourceField(t *testing.T) { m := testManifest() p := m.GetPluginByName("analytics") diff --git a/libs/apps/generator/generator.go b/libs/apps/generator/generator.go index 453a61e219..02a3afbc77 100644 --- a/libs/apps/generator/generator.go +++ b/libs/apps/generator/generator.go @@ -160,8 +160,11 @@ func dotEnvActualLines(r manifest.Resource, cfg Config) []string { if field.Env == "" || !validEnvVar.MatchString(field.Env) { continue } - value := sanitizeEnvValue(cfg.ResourceValues[r.Key()+"."+fieldName]) - lines = append(lines, fmt.Sprintf("%s=%s", field.Env, value)) + value := cfg.ResourceValues[r.Key()+"."+fieldName] + if value == "" && field.Value != "" { + value = field.Value + } + lines = append(lines, fmt.Sprintf("%s=%s", field.Env, sanitizeEnvValue(value))) } return lines } @@ -176,6 +179,9 @@ func dotEnvExampleLines(r manifest.Resource, commented bool) []string { continue } placeholder := "your_" + r.VarPrefix() + "_" + fieldName + if field.Value != "" { + placeholder = field.Value + } if commented { lines = append(lines, fmt.Sprintf("# %s=%s", field.Env, placeholder)) } else { @@ -251,6 +257,9 @@ func appEnvLines(r manifest.Resource) []string { if field.Env == "" || !validEnvVar.MatchString(field.Env) { continue } + if field.LocalOnly { + continue + } lines = append(lines, " - name: "+field.Env, " valueFrom: "+r.Key(), @@ -357,7 +366,7 @@ func variableNamesForResource(r manifest.Resource) []varInfo { for _, fieldName := range r.FieldNames() { field := r.Fields[fieldName] - if field.BundleIgnore { + if field.BundleIgnore || field.LocalOnly { covered[fieldName] = true continue } diff --git a/libs/apps/generator/generator_test.go b/libs/apps/generator/generator_test.go index ed733eb40a..9587353a2c 100644 --- a/libs/apps/generator/generator_test.go +++ b/libs/apps/generator/generator_test.go @@ -864,6 +864,143 @@ func TestGenerateDotEnvSanitizesNewlines(t *testing.T) { assert.NotContains(t, result, "\n") } +func TestLocalOnlyFieldSkippedInAppEnvAndVariables(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "test", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + { + Type: "postgres", Alias: "Postgres", ResourceKey: "postgres", + Fields: map[string]manifest.ResourceField{ + "branch": {Description: "Branch"}, + "database": {Description: "Database"}, + "host": {Env: "PGHOST", LocalOnly: true, Resolve: "postgres:host", Description: "Postgres host"}, + "databaseName": {Env: "PGDATABASE", LocalOnly: true, Resolve: "postgres:databaseName", Description: "Postgres db name"}, + "endpointPath": {Env: "LAKEBASE_ENDPOINT", BundleIgnore: true, Resolve: "postgres:endpointPath", Description: "Endpoint"}, + "port": {Env: "PGPORT", LocalOnly: true, Value: "5432", Description: "Postgres port"}, + "sslmode": {Env: "PGSSLMODE", LocalOnly: true, Value: "require", Description: "SSL mode"}, + }, + }, + }, + }, + }, + } + cfg := generator.Config{ResourceValues: map[string]string{ + "postgres.branch": "projects/p1/branches/br-1", + "postgres.database": "projects/p1/branches/br-1/databases/db-1", + "postgres.host": "ep-test.database.cloud.databricks.com", + "postgres.databaseName": "mydb", + "postgres.endpointPath": "projects/p1/branches/br-1/endpoints/primary", + }} + + // localOnly fields should be in .env with resolved values + env := generator.GenerateDotEnv(plugins, cfg) + assert.Contains(t, env, "PGHOST=ep-test.database.cloud.databricks.com") + assert.Contains(t, env, "PGDATABASE=mydb") + assert.Contains(t, env, "LAKEBASE_ENDPOINT=projects/p1/branches/br-1/endpoints/primary") + assert.Contains(t, env, "PGPORT=5432") + assert.Contains(t, env, "PGSSLMODE=require") + + // localOnly fields should be in .env.example + example := generator.GenerateDotEnvExample(plugins) + assert.Contains(t, example, "PGHOST=your_postgres_host") + assert.Contains(t, example, "PGDATABASE=your_postgres_databaseName") + assert.Contains(t, example, "LAKEBASE_ENDPOINT=your_postgres_endpointPath") + assert.Contains(t, example, "PGPORT=5432") + assert.Contains(t, example, "PGSSLMODE=require") + + // localOnly fields should NOT be in app.yaml (appEnv) + appEnv := generator.GenerateAppEnv(plugins, cfg) + assert.NotContains(t, appEnv, "PGHOST") + assert.NotContains(t, appEnv, "PGDATABASE") + assert.NotContains(t, appEnv, "PGPORT") + assert.NotContains(t, appEnv, "PGSSLMODE") + // bundleIgnore fields without localOnly SHOULD be in app.yaml + assert.Contains(t, appEnv, "LAKEBASE_ENDPOINT") + assert.Contains(t, appEnv, "valueFrom: postgres") + + // localOnly fields should NOT be in bundle variables + vars := generator.GenerateBundleVariables(plugins, cfg) + assert.Contains(t, vars, "postgres_branch:") + assert.Contains(t, vars, "postgres_database:") + assert.NotContains(t, vars, "postgres_host:") + assert.NotContains(t, vars, "postgres_dbname:") + assert.NotContains(t, vars, "postgres_endpoint:") + assert.NotContains(t, vars, "postgres_port:") + assert.NotContains(t, vars, "postgres_sslmode:") + + // localOnly fields should NOT be in target variables + target := generator.GenerateTargetVariables(plugins, cfg) + assert.Contains(t, target, "postgres_branch: projects/p1/branches/br-1") + assert.Contains(t, target, "postgres_database: projects/p1/branches/br-1/databases/db-1") + assert.NotContains(t, target, "postgres_host") + assert.NotContains(t, target, "postgres_databaseName") + assert.NotContains(t, target, "postgres_endpointPath") + assert.NotContains(t, target, "postgres_port") + assert.NotContains(t, target, "postgres_sslmode") +} + +func TestValueFieldFallback(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "test", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + { + Type: "postgres", Alias: "Postgres", ResourceKey: "postgres", + Fields: map[string]manifest.ResourceField{ + "port": {Env: "PGPORT", LocalOnly: true, Value: "5432", Description: "Postgres port"}, + "sslmode": {Env: "PGSSLMODE", LocalOnly: true, Value: "require", Description: "SSL mode"}, + "host": {Env: "PGHOST", LocalOnly: true, Resolve: "postgres:host", Description: "Postgres host"}, + }, + }, + }, + }, + }, + } + + // No ResourceValues provided — value fields should use static fallback, resolve fields should be empty + cfg := generator.Config{ResourceValues: map[string]string{}} + + env := generator.GenerateDotEnv(plugins, cfg) + assert.Contains(t, env, "PGPORT=5432") + assert.Contains(t, env, "PGSSLMODE=require") + assert.Contains(t, env, "PGHOST=") + + // value fields should show static value in example too + example := generator.GenerateDotEnvExample(plugins) + assert.Contains(t, example, "PGPORT=5432") + assert.Contains(t, example, "PGSSLMODE=require") + assert.Contains(t, example, "PGHOST=your_postgres_host") +} + +func TestValueFieldNotOverriddenByResourceValues(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "test", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + { + Type: "postgres", Alias: "Postgres", ResourceKey: "postgres", + Fields: map[string]manifest.ResourceField{ + "port": {Env: "PGPORT", LocalOnly: true, Value: "5432", Description: "Postgres port"}, + }, + }, + }, + }, + }, + } + + // Explicit ResourceValues should take precedence over static value + cfg := generator.Config{ResourceValues: map[string]string{ + "postgres.port": "5433", + }} + + env := generator.GenerateDotEnv(plugins, cfg) + assert.Contains(t, env, "PGPORT=5433") +} + func TestBundleIgnoreFieldSkippedInVariablesAndTargets(t *testing.T) { plugins := []manifest.Plugin{ { diff --git a/libs/apps/manifest/manifest.go b/libs/apps/manifest/manifest.go index 1478d2af38..21ea16c8e4 100644 --- a/libs/apps/manifest/manifest.go +++ b/libs/apps/manifest/manifest.go @@ -17,6 +17,9 @@ type ResourceField struct { Env string `json:"env"` Description string `json:"description"` BundleIgnore bool `json:"bundleIgnore,omitempty"` + LocalOnly bool `json:"localOnly,omitempty"` + Value string `json:"value,omitempty"` + Resolve string `json:"resolve,omitempty"` } // Resource defines a Databricks resource required or optional for a plugin. diff --git a/libs/apps/prompt/listers.go b/libs/apps/prompt/listers.go index e5d075f5fd..a78c28962a 100644 --- a/libs/apps/prompt/listers.go +++ b/libs/apps/prompt/listers.go @@ -430,6 +430,17 @@ func ListPostgresDatabases(ctx context.Context, branchName string) ([]ListItem, return out, nil } +// ListPostgresEndpoints returns endpoints for a branch as raw Endpoint objects. +// Returns raw objects (not ListItem) since we need multiple fields (Name, Status.Hosts.Host). +func ListPostgresEndpoints(ctx context.Context, branchName string) ([]postgres.Endpoint, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + iter := w.Postgres.ListEndpoints(ctx, postgres.ListEndpointsRequest{Parent: branchName}) + return listing.ToSlice(ctx, iter) +} + // ListGenieSpaces returns Genie spaces as selectable items. func ListGenieSpaces(ctx context.Context) ([]ListItem, error) { w, err := workspaceClient(ctx) diff --git a/libs/apps/prompt/prompt.go b/libs/apps/prompt/prompt.go index 3b57ae1ccc..1c671ec176 100644 --- a/libs/apps/prompt/prompt.go +++ b/libs/apps/prompt/prompt.go @@ -15,8 +15,10 @@ import ( "github.com/databricks/cli/libs/apps/manifest" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go/listing" "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/databricks/databricks-sdk-go/service/postgres" "github.com/databricks/databricks-sdk-go/service/sql" ) @@ -562,6 +564,29 @@ func PromptForPostgres(ctx context.Context, r manifest.Resource, required bool) return nil, nil } + // Step 2.5: resolve endpoint details from the branch (non-fatal). + var host, endpointPath string + endpointErr := RunWithSpinnerCtx(ctx, "Resolving connection details...", func() error { + endpoints, fetchErr := ListPostgresEndpoints(ctx, branchName) + if fetchErr != nil { + return fetchErr + } + for _, ep := range endpoints { + if ep.Status != nil && ep.Status.EndpointType == postgres.EndpointTypeEndpointTypeReadWrite { + endpointPath = ep.Name + if ep.Status.Hosts != nil && ep.Status.Hosts.Host != "" { + host = ep.Status.Hosts.Host + } + break + } + } + return nil + }) + if endpointErr != nil { + log.Warnf(ctx, "Could not resolve endpoint details: %v", err) + // non-fatal: user can fill values manually + } + // Step 3: pick a database within the branch var databases []ListItem err = RunWithSpinnerCtx(ctx, "Fetching databases...", func() error { @@ -572,7 +597,7 @@ func PromptForPostgres(ctx context.Context, r manifest.Resource, required bool) if err != nil { return nil, err } - dbName, err := PromptFromList(ctx, "Select Database", "no databases found in branch "+branchName, databases, required) + dbName, pgDatabaseName, err := promptFromListWithLabel(ctx, "Select Database", "no databases found in branch "+branchName, databases, required) if err != nil { return nil, err } @@ -580,10 +605,37 @@ func PromptForPostgres(ctx context.Context, r manifest.Resource, required bool) return nil, nil } - return map[string]string{ + // Build resolver results map keyed by resolver name. + resolvedValues := map[string]string{ + "postgres:host": host, + "postgres:databaseName": pgDatabaseName, + "postgres:endpointPath": endpointPath, + } + + // Start with prompted values (fields without resolve). + result := map[string]string{ r.Key() + ".branch": branchName, r.Key() + ".database": dbName, - }, nil + } + + // Map resolved values to fields using the manifest's resolve property. + applyResolvedValues(r, resolvedValues, result) + + return result, nil +} + +// applyResolvedValues populates result with values from resolvedValues, +// using the manifest's resolve property to map resolver names to field names. +func applyResolvedValues(r manifest.Resource, resolvedValues, result map[string]string) { + for _, fieldName := range r.FieldNames() { + field := r.Fields[fieldName] + if field.Resolve == "" { + continue + } + if val, ok := resolvedValues[field.Resolve]; ok { + result[r.Key()+"."+fieldName] = val + } + } } // PromptForGenieSpace shows a picker for Genie spaces. diff --git a/libs/apps/prompt/prompt_test.go b/libs/apps/prompt/prompt_test.go index caa813df1d..794f0f6b9b 100644 --- a/libs/apps/prompt/prompt_test.go +++ b/libs/apps/prompt/prompt_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/databricks/cli/libs/apps/manifest" "github.com/databricks/cli/libs/cmdio" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -167,6 +168,112 @@ func TestRunModeConstants(t *testing.T) { assert.Equal(t, RunModeDevRemote, RunMode("dev-remote")) } +func TestApplyResolvedValues(t *testing.T) { + t.Run("maps resolver names to manifest field names", func(t *testing.T) { + r := manifest.Resource{ + ResourceKey: "postgres", + Fields: map[string]manifest.ResourceField{ + "branch": {Description: "branch path"}, + "database": {Description: "database name"}, + "host": {Resolve: "postgres:host"}, + "databaseName": {Resolve: "postgres:databaseName"}, + "endpointPath": {Resolve: "postgres:endpointPath"}, + "port": {Value: "5432"}, + }, + } + + resolvedValues := map[string]string{ + "postgres:host": "my-host.example.com", + "postgres:databaseName": "my_db", + "postgres:endpointPath": "projects/p1/branches/b1/endpoints/e1", + } + + result := map[string]string{ + "postgres.branch": "projects/p1/branches/b1", + "postgres.database": "projects/p1/branches/b1/databases/d1", + } + + applyResolvedValues(r, resolvedValues, result) + + assert.Equal(t, map[string]string{ + "postgres.branch": "projects/p1/branches/b1", + "postgres.database": "projects/p1/branches/b1/databases/d1", + "postgres.host": "my-host.example.com", + "postgres.databaseName": "my_db", + "postgres.endpointPath": "projects/p1/branches/b1/endpoints/e1", + }, result) + }) + + t.Run("renamed fields still map via resolver", func(t *testing.T) { + r := manifest.Resource{ + ResourceKey: "postgres", + Fields: map[string]manifest.ResourceField{ + "pg_host": {Resolve: "postgres:host"}, + "pg_database": {Resolve: "postgres:databaseName"}, + "pg_endpoint": {Resolve: "postgres:endpointPath"}, + }, + } + + resolvedValues := map[string]string{ + "postgres:host": "host.example.com", + "postgres:databaseName": "testdb", + "postgres:endpointPath": "projects/p1/branches/b1/endpoints/e1", + } + + result := map[string]string{} + applyResolvedValues(r, resolvedValues, result) + + assert.Equal(t, map[string]string{ + "postgres.pg_host": "host.example.com", + "postgres.pg_database": "testdb", + "postgres.pg_endpoint": "projects/p1/branches/b1/endpoints/e1", + }, result) + }) + + t.Run("skips fields without resolve", func(t *testing.T) { + r := manifest.Resource{ + ResourceKey: "postgres", + Fields: map[string]manifest.ResourceField{ + "branch": {Description: "no resolve"}, + "host": {Resolve: "postgres:host"}, + "port": {Value: "5432"}, + }, + } + + resolvedValues := map[string]string{ + "postgres:host": "my-host", + } + + result := map[string]string{} + applyResolvedValues(r, resolvedValues, result) + + assert.Equal(t, map[string]string{ + "postgres.host": "my-host", + }, result) + }) + + t.Run("skips resolve values not in resolvedValues map", func(t *testing.T) { + r := manifest.Resource{ + ResourceKey: "postgres", + Fields: map[string]manifest.ResourceField{ + "host": {Resolve: "postgres:host"}, + "unknown": {Resolve: "postgres:unknownResolver"}, + }, + } + + resolvedValues := map[string]string{ + "postgres:host": "my-host", + } + + result := map[string]string{} + applyResolvedValues(r, resolvedValues, result) + + assert.Equal(t, map[string]string{ + "postgres.host": "my-host", + }, result) + }) +} + func TestMaxAppNameLength(t *testing.T) { // Verify the constant is set correctly assert.Equal(t, 30, MaxAppNameLength) From 172c0fc35e875fc0a79812d0ba17d071d08c958c Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Wed, 11 Mar 2026 11:25:23 +0100 Subject: [PATCH 2/2] fix: address PR review comments for error variable and test field names --- libs/apps/generator/generator_test.go | 4 ++-- libs/apps/prompt/prompt.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/apps/generator/generator_test.go b/libs/apps/generator/generator_test.go index 9587353a2c..fd4f85cf45 100644 --- a/libs/apps/generator/generator_test.go +++ b/libs/apps/generator/generator_test.go @@ -925,8 +925,8 @@ func TestLocalOnlyFieldSkippedInAppEnvAndVariables(t *testing.T) { assert.Contains(t, vars, "postgres_branch:") assert.Contains(t, vars, "postgres_database:") assert.NotContains(t, vars, "postgres_host:") - assert.NotContains(t, vars, "postgres_dbname:") - assert.NotContains(t, vars, "postgres_endpoint:") + assert.NotContains(t, vars, "postgres_databaseName:") + assert.NotContains(t, vars, "postgres_endpointPath:") assert.NotContains(t, vars, "postgres_port:") assert.NotContains(t, vars, "postgres_sslmode:") diff --git a/libs/apps/prompt/prompt.go b/libs/apps/prompt/prompt.go index 1c671ec176..d24d3a8715 100644 --- a/libs/apps/prompt/prompt.go +++ b/libs/apps/prompt/prompt.go @@ -583,7 +583,7 @@ func PromptForPostgres(ctx context.Context, r manifest.Resource, required bool) return nil }) if endpointErr != nil { - log.Warnf(ctx, "Could not resolve endpoint details: %v", err) + log.Warnf(ctx, "Could not resolve endpoint details: %v", endpointErr) // non-fatal: user can fill values manually }