Skip to content
Prev Previous commit
Next Next commit
OS ENV fall back logic,
os < .env
  • Loading branch information
royendo committed Jan 13, 2026
commit 9a3ccdfc3880f9c37e1027f0d0d55ffd55223ce9
1,425 changes: 719 additions & 706 deletions proto/gen/rill/runtime/v1/api.pb.go

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions proto/gen/rill/runtime/v1/runtime.swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3958,6 +3958,13 @@ definitions:
errorMessage:
type: string
title: Error message if the connector is misconfigured
osEnvVariables:
type: array
items:
type: string
description: |-
Variables that were resolved from OS environment instead of .env files.
Used to show warnings in the UI when credentials come from OS env.
description: AnalyzedConnector contains information about a connector that is referenced in the project files.
v1AnalyzedVariable:
type: object
Expand Down
3 changes: 3 additions & 0 deletions proto/rill/runtime/v1/api.proto
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,9 @@ message AnalyzedConnector {
repeated ResourceName used_by = 8;
// Error message if the connector is misconfigured
string error_message = 9;
// Variables that were resolved from OS environment instead of .env files.
// Used to show warnings in the UI when credentials come from OS env.
repeated string os_env_variables = 12;
}

// Request message for RuntimeService.ListConnectorDrivers
Expand Down
69 changes: 59 additions & 10 deletions runtime/connections.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package runtime
import (
"context"
"fmt"
"os"
"strconv"
"strings"

Expand Down Expand Up @@ -255,22 +256,23 @@ func (r *Runtime) ConnectorConfig(ctx context.Context, instanceID, name string)

// For backwards compatibility, certain root-level variables apply to certain implicit connectors.
// NOTE: This switches on connector.Name, not connector.Type, because this only applies to implicit connectors.
// Uses OS environment variable fallback for cloud credentials.
switch name {
case "s3", "athena", "redshift":
res.setPreset("aws_access_key_id", vars["aws_access_key_id"], false)
res.setPreset("aws_secret_access_key", vars["aws_secret_access_key"], false)
res.setPreset("aws_session_token", vars["aws_session_token"], false)
res.setPresetWithOSFallback("aws_access_key_id", vars["aws_access_key_id"], vars)
res.setPresetWithOSFallback("aws_secret_access_key", vars["aws_secret_access_key"], vars)
res.setPresetWithOSFallback("aws_session_token", vars["aws_session_token"], vars)
case "azure":
res.setPreset("azure_storage_account", vars["azure_storage_account"], false)
res.setPreset("azure_storage_key", vars["azure_storage_key"], false)
res.setPreset("azure_storage_sas_token", vars["azure_storage_sas_token"], false)
res.setPreset("azure_storage_connection_string", vars["azure_storage_connection_string"], false)
res.setPresetWithOSFallback("azure_storage_account", vars["azure_storage_account"], vars)
res.setPresetWithOSFallback("azure_storage_key", vars["azure_storage_key"], vars)
res.setPresetWithOSFallback("azure_storage_sas_token", vars["azure_storage_sas_token"], vars)
res.setPresetWithOSFallback("azure_storage_connection_string", vars["azure_storage_connection_string"], vars)
case "gcs":
res.setPreset("google_application_credentials", vars["google_application_credentials"], false)
res.setPresetWithOSFallback("google_application_credentials", vars["google_application_credentials"], vars)
case "bigquery":
res.setPreset("google_application_credentials", vars["google_application_credentials"], false)
res.setPresetWithOSFallback("google_application_credentials", vars["google_application_credentials"], vars)
case "motherduck":
res.setPreset("token", vars["token"], false)
res.setPresetWithOSFallback("token", vars["token"], vars)
res.setPreset("dsn", "", true)
case "local_file":
// The "local_file" connector needs to know the repo root.
Expand Down Expand Up @@ -308,6 +310,7 @@ func resolveConnectorProperties(environment string, vars map[string]string, c *r
td := parser.TemplateData{
Environment: environment,
Variables: vars,
OSEnvVars: make(map[string]bool),
}

for _, k := range c.TemplatedProperties {
Expand Down Expand Up @@ -339,6 +342,9 @@ type ConnectorConfig struct {
Provision bool
// ProvisionArgs provide provisioning args for when ProvisionName is set.
ProvisionArgs map[string]any
// OSEnvVars tracks variables that were resolved from OS environment instead of .env files.
// This is used to show warnings in the UI when credentials come from OS env.
OSEnvVars map[string]bool
}

// Resolve returns the final resolved connector configuration.
Expand Down Expand Up @@ -372,3 +378,46 @@ func (c *ConnectorConfig) setPreset(k, v string, force bool) {
}
c.Preset[k] = v
}

// setPresetWithOSFallback sets a preset value, falling back to OS environment variable if the value is empty.
// It tracks whether the value came from OS env in the OSEnvVars map.
func (c *ConnectorConfig) setPresetWithOSFallback(k, v string, vars map[string]string) {
if v != "" {
// Value found in .env or project variables
if c.Preset == nil {
c.Preset = make(map[string]any)
}
c.Preset[k] = v
return
}

// Try OS environment variable fallback
// Check exact match first
if osVal := os.Getenv(k); osVal != "" {
if c.Preset == nil {
c.Preset = make(map[string]any)
}
c.Preset[k] = osVal
c.trackOSEnvVar(k)
return
}

// Try uppercase variant (common for env vars like AWS_ACCESS_KEY_ID)
upperKey := strings.ToUpper(k)
if osVal := os.Getenv(upperKey); osVal != "" {
if c.Preset == nil {
c.Preset = make(map[string]any)
}
c.Preset[k] = osVal
c.trackOSEnvVar(upperKey)
return
}
}

// trackOSEnvVar marks a variable as coming from OS environment.
func (c *ConnectorConfig) trackOSEnvVar(name string) {
if c.OSEnvVars == nil {
c.OSEnvVars = make(map[string]bool)
}
c.OSEnvVars[name] = true
}
80 changes: 75 additions & 5 deletions runtime/parser/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package parser
import (
"bytes"
"fmt"
"os"
"reflect"
"strings"
"text/template"
Expand Down Expand Up @@ -55,6 +56,9 @@ type TemplateData struct {
Self TemplateResource
Resolve func(ref ResourceName) (string, error)
Lookup func(name ResourceName) (TemplateResource, error)
// OSEnvVars tracks variables that were resolved from OS environment instead of .env files.
// This is used to show warnings in the UI when credentials come from OS env.
OSEnvVars map[string]bool
}

// TemplateResource contains data for a resource for injection into a template.
Expand Down Expand Up @@ -270,6 +274,7 @@ func ResolveTemplate(tmpl string, data TemplateData, errOnMissingTemplKeys bool)
}

// Add func to access environment variables (case-insensitive)
// Falls back to OS environment variables if not found in .env files
funcMap["env"] = func(name string) (string, error) {
if name == "" {
return "", fmt.Errorf(`"env" requires a variable name argument`)
Expand All @@ -284,6 +289,23 @@ func ResolveTemplate(tmpl string, data TemplateData, errOnMissingTemplKeys bool)
return value, nil
}
}
// Fallback to OS environment variable
if value := os.Getenv(name); value != "" {
// Track that this variable came from OS env
if data.OSEnvVars != nil {
data.OSEnvVars[name] = true
}
return value, nil
}
// Try case-insensitive OS env lookup (check common variations)
for _, variant := range []string{strings.ToUpper(name), strings.ToLower(name)} {
if value := os.Getenv(variant); value != "" {
if data.OSEnvVars != nil {
data.OSEnvVars[variant] = true
}
return value, nil
}
}
return "", fmt.Errorf(`environment variable "%s" not found`, name)
}

Expand All @@ -300,12 +322,60 @@ func ResolveTemplate(tmpl string, data TemplateData, errOnMissingTemplKeys bool)
return "", err
}

// Split variables that contain dots into nested maps.
var vars map[string]any
if len(data.Variables) > 0 {
vars = map[string]any{}
}
// Extract variable references from the template to check for OS env fallback.
// Variables like "env.AWS_ACCESS_KEY_ID" need to be populated from OS env if not in data.Variables.
referencedVars := extractVariablesFromTemplate(t.Tree)

varsWithOSFallback := make(map[string]string, len(data.Variables))
for k, v := range data.Variables {
varsWithOSFallback[k] = v
}

// Check for OS env fallback for referenced variables not in data.Variables
for _, refVar := range referencedVars {
// Handle env.VAR_NAME references
if strings.HasPrefix(refVar, "env.") {
varName := strings.TrimPrefix(refVar, "env.")
// Check if already in variables (case-insensitive check)
// If found with different case, also add with the case the template expects
foundKey := ""
for k := range varsWithOSFallback {
if strings.EqualFold(k, varName) {
foundKey = k
break
}
}
if foundKey != "" {
// If the case doesn't match exactly, also add with the expected case
if foundKey != varName {
varsWithOSFallback[varName] = varsWithOSFallback[foundKey]
}
continue
}
// Try OS env fallback (try exact case, uppercase, and lowercase)
if osVal := os.Getenv(varName); osVal != "" {
varsWithOSFallback[varName] = osVal
if data.OSEnvVars != nil {
data.OSEnvVars[varName] = true
}
} else if osVal := os.Getenv(strings.ToUpper(varName)); osVal != "" {
varsWithOSFallback[varName] = osVal
if data.OSEnvVars != nil {
data.OSEnvVars[strings.ToUpper(varName)] = true
}
} else if osVal := os.Getenv(strings.ToLower(varName)); osVal != "" {
varsWithOSFallback[varName] = osVal
if data.OSEnvVars != nil {
data.OSEnvVars[strings.ToLower(varName)] = true
}
}
}
}

// Split variables that contain dots into nested maps.
// Always initialize vars to ensure .env.X access doesn't fail on nil map.
vars := map[string]any{}
for k, v := range varsWithOSFallback {
// Note: We always add the full variable name (including dots) at the top level.
vars[k] = v

Expand Down
93 changes: 93 additions & 0 deletions runtime/parser/template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,99 @@ func TestEnvFunction(t *testing.T) {
}
}

func TestOSEnvFallback(t *testing.T) {
// Set OS environment variables for testing
t.Setenv("TEST_OS_ENV_VAR", "os_value")
t.Setenv("AWS_ACCESS_KEY_ID", "test_key_id")
t.Setenv("aws_secret_access_key", "test_secret")

tests := []struct {
name string
template string
data TemplateData
want string
wantErr bool
osEnvVars []string // Expected OS env vars that were used
}{
{
name: "env function falls back to OS env",
template: `key={{ env "TEST_OS_ENV_VAR" }}`,
data: TemplateData{
Variables: map[string]string{}, // Empty - no .env vars
OSEnvVars: make(map[string]bool),
},
want: "key=os_value",
wantErr: false,
osEnvVars: []string{"TEST_OS_ENV_VAR"},
},
{
name: "dot notation falls back to OS env",
template: `key={{ .env.TEST_OS_ENV_VAR }}`,
data: TemplateData{
Variables: map[string]string{}, // Empty - no .env vars
OSEnvVars: make(map[string]bool),
},
want: "key=os_value",
wantErr: false,
osEnvVars: []string{"TEST_OS_ENV_VAR"},
},
{
name: "AWS credentials from OS env",
template: `SELECT '{{ env "AWS_ACCESS_KEY_ID" }}' as key, '{{ env "aws_secret_access_key" }}' as secret`,
data: TemplateData{
Variables: map[string]string{},
OSEnvVars: make(map[string]bool),
},
want: "SELECT 'test_key_id' as key, 'test_secret' as secret",
wantErr: false,
osEnvVars: []string{"AWS_ACCESS_KEY_ID", "aws_secret_access_key"},
},
{
name: "AWS credentials from OS env using dot notation",
template: `SELECT '{{ .env.AWS_ACCESS_KEY_ID }}' as key, '{{ .env.aws_secret_access_key }}' as secret`,
data: TemplateData{
Variables: map[string]string{},
OSEnvVars: make(map[string]bool),
},
want: "SELECT 'test_key_id' as key, 'test_secret' as secret",
wantErr: false,
osEnvVars: []string{"AWS_ACCESS_KEY_ID", "aws_secret_access_key"},
},
{
name: ".env takes precedence over OS env",
template: `key={{ env "TEST_OS_ENV_VAR" }}`,
data: TemplateData{
Variables: map[string]string{
"TEST_OS_ENV_VAR": "dotenv_value",
},
OSEnvVars: make(map[string]bool),
},
want: "key=dotenv_value",
wantErr: false,
osEnvVars: []string{}, // Should NOT use OS env
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resolved, err := ResolveTemplate(tt.template, tt.data, false)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tt.want, resolved)

// Check that the expected OS env vars were tracked
for _, envVar := range tt.osEnvVars {
require.True(t, tt.data.OSEnvVars[envVar], "Expected OS env var %s to be tracked", envVar)
}
// Check that no extra OS env vars were tracked
require.Equal(t, len(tt.osEnvVars), len(tt.data.OSEnvVars), "Unexpected OS env vars tracked")
}
})
}
}

func TestAsSQLList(t *testing.T) {
tests := []struct {
name string
Expand Down
7 changes: 7 additions & 0 deletions runtime/server/connectors.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ func (s *Server) AnalyzeConnectors(ctx context.Context, req *runtimev1.AnalyzeCo
}
}

// Convert OS env vars map to slice
var osEnvVars []string
for k := range cfg.OSEnvVars {
osEnvVars = append(osEnvVars, k)
}

c := &runtimev1.AnalyzedConnector{
Name: connector.Name,
Driver: driverSpecToPB(connector.Driver, connector.Spec),
Expand All @@ -110,6 +116,7 @@ func (s *Server) AnalyzeConnectors(ctx context.Context, req *runtimev1.AnalyzeCo
ProvisionArgs: provisionArgsPB,
HasAnonymousAccess: connector.AnonymousAccess,
UsedBy: nil,
OsEnvVariables: osEnvVars,
}

for _, r := range connector.Resources {
Expand Down
9 changes: 9 additions & 0 deletions web-common/src/proto/gen/rill/runtime/v1/api_pb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4041,6 +4041,14 @@ export class AnalyzedConnector extends Message$1<AnalyzedConnector> {
*/
errorMessage = "";

/**
* Variables that were resolved from OS environment instead of .env files.
* Used to show warnings in the UI when credentials come from OS env.
*
* @generated from field: repeated string os_env_variables = 12;
*/
osEnvVariables: string[] = [];

constructor(data?: PartialMessage<AnalyzedConnector>) {
super();
proto3.util.initPartial(data, this);
Expand All @@ -4060,6 +4068,7 @@ export class AnalyzedConnector extends Message$1<AnalyzedConnector> {
{ no: 7, name: "has_anonymous_access", kind: "scalar", T: 8 /* ScalarType.BOOL */ },
{ no: 8, name: "used_by", kind: "message", T: ResourceName, repeated: true },
{ no: 9, name: "error_message", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 12, name: "os_env_variables", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true },
]);

static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): AnalyzedConnector {
Expand Down
Loading