Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/scripts/test-recipe.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,31 @@ ensure_workspace_context() {
rad workspace switch "$WORKSPACE_NAME" >/dev/null 2>&1 || true
}

validate_connection_environment_variables() {
if [[ "$PLATFORM" != "kubernetes" ]]; then
return 0
fi

if [[ "$RESOURCE_TYPE" == "Radius.Compute/containers" ]]; then
echo "==> Validating direct Secret connection environment variables"
local deployment_json
deployment_json=$(kubectl get deployment myapp -n testapp -o json) || return 1

echo "$deployment_json" | jq -e '
(.spec.template.spec.containers[] | select(.name == "orderprocessor").env) as $app |
(.spec.template.spec.initContainers[] | select(.name == "dbmigration").env) as $init |
($app | any(.name == "CONNECTION_SECRETS_USERNAME" and .value == "explicit-user")) and
($app | any(.name == "CONNECTION_SECRETS_PASSWORD" and .valueFrom.secretKeyRef.key == "password")) and
($app | any(.name == "CONNECTION_SECRETS_APIKEY" and .valueFrom.secretKeyRef.key == "apikey")) and
($app | all(.name | startswith("CONNECTION_DISABLEDSECRETS_") | not)) and
($init | any(.name == "CONNECTION_SECRETS_USERNAME" and .valueFrom.secretKeyRef.key == "username")) and
($init | any(.name == "CONNECTION_SECRETS_PASSWORD" and .valueFrom.secretKeyRef.key == "password")) and
($init | any(.name == "CONNECTION_SECRETS_APIKEY" and .valueFrom.secretKeyRef.key == "apikey")) and
($init | all(.name | startswith("CONNECTION_DISABLEDSECRETS_") | not))
' >/dev/null
fi
}

resolve_environment_path() {
# Resolve the full environment resource ID to avoid hardcoding the provider path
if ! ENVIRONMENT_JSON=$(rad env show "$ENVIRONMENT_NAME" --workspace "$WORKSPACE_NAME" -o json --preview 2>/dev/null); then
Expand Down Expand Up @@ -180,6 +205,15 @@ fi
# Deploy the test app
if rad deploy "$TEST_FILE" --application "$APP_NAME" -e "$ENVIRONMENT_PATH" $PARAMS; then
echo "==> Test deployment successful"

if ! validate_connection_environment_variables; then
echo "==> Connection environment variable validation failed"
rad app delete "$APP_NAME" --yes 2>/dev/null || true
kubectl delete secrets --all -n testapp 2>/dev/null || true
kubectl delete deployments --all -n testapp 2>/dev/null || true
kubectl delete services --all -n testapp 2>/dev/null || true
exit 1
fi

# Cleanup: delete the app
echo "==> Cleaning up test application"
Expand Down
10 changes: 10 additions & 0 deletions Compute/containers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ A list of available Recipes for this Resource Type, including links to the Bicep
| context.resource.properties.extensions | Dapr extension for Radius |
| context.resource.properties.platformOptions | Kubernetes Deployment and Pod override properties |

### Connections and secrets

For ordinary connections, the Kubernetes Recipes preserve the existing behavior of injecting scalar metadata from `context.resource.connections.<name>` and values from `context.resource.connections.<name>.properties` as `CONNECTION_<CONNECTION-NAME>_<PROPERTY-NAME>` environment variables. When a producer Recipe returns secrets, Radius supplies reference metadata under `context.resource.connections.<name>.secrets`; the same connection injects each secret through a Kubernetes `secretKeyRef`. For example, Redis connection `redis` supplies its ordinary `host` and `port` values together with the secret-backed `CONNECTION_REDIS_URL`.

Direct connections to user-authored `Radius.Security/secrets` resources remain supported and inject one secret-backed variable per data key. Both regular and init containers receive generated variables. Explicit container environment variables take precedence, managed secret references take precedence over ordinary properties with the same generated name, and `disableDefaultEnvVars: true` disables both ordinary and secret-backed variables for that connection.

Connection names, property names, and secret names are uppercased when generating environment variable names. Secret names that collide after uppercasing are rejected. Direct Secret connections now fully uppercase the generated variable name, including the data key; this intentionally replaces the previous `envFrom` behavior, which preserved the Secret data key's casing after the uppercase connection prefix. The Kubernetes Secret name is derived from the final segment of each full Radius Secret resource ID. Secret values remain in Kubernetes references and are never copied into Recipe output or plaintext container configuration.

The Azure ACI Recipe is unchanged and does not consume the Kubernetes secret reference metadata described above.

Note: The Azure ACI recipe does not support `context.resource.properties.extensions.daprSidecar` and ignores Dapr sidecar configuration provided through `extensions`.
Note: The Azure ACI recipe does not support `context.resource.properties.replicas` or `context.resource.properties.autoScaling.*`; scaling is controlled by recipe-specific parameters (`desiredCount` and `maintainDesiredCount`).
Note: The Azure ACI recipe does not support `context.resource.properties.containers.args` or `context.resource.properties.containers.workingDir`; `args` are only used by merging into the ACI `command` array, and `workingDir` is ignored.
Expand Down
9 changes: 8 additions & 1 deletion Compute/containers/containers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ types:

To mount a persistent volume or secret see the PersistentVolumes and Secrets Resource Types.

On Kubernetes, each connection injects ordinary producer properties and
Recipe-managed secret references using
`CONNECTION_<CONNECTION-NAME>_<PROPERTY-NAME>`. Explicit environment variables
take precedence, followed by managed secret references, then ordinary values.
Set `disableDefaultEnvVars: true` to disable all generated variables for a
connection. This behavior applies to regular and init containers.

apiVersions:
'2025-08-01-preview':
schema:
Expand Down Expand Up @@ -117,7 +124,7 @@ types:
description: (Required) The resource ID of the resource this container is dependent upon.
disableDefaultEnvVars:
type: boolean
description: (Optional) Disables the automatic injection of environment variables from connected resource properties.
description: (Optional) Disables automatic injection of ordinary properties and secret-backed environment variables from this connection.
required: [source]
containers:
type: object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,31 +40,66 @@ var resourceConnections = context.resource.?connections ?? {}
var connectionDefinitions = context.resource.properties.?connections ?? {}

// Properties to exclude from connection environment variables
var excludedProperties = ['recipe', 'status', 'provisioningState']
var excludedProperties = ['recipe', 'secrets', 'status', 'provisioningState']
var topLevelExcludedProperties = ['recipe', 'secrets', 'status', 'provisioningState', 'properties', 'secretName']

// Helper function to check if a connection is a secrets resource (using source from original connection definition)
var isSecretsResource = reduce(items(connectionDefinitions), {}, (acc, conn) => union(acc, {
'${conn.key}': contains(string(conn.value.?source ?? ''), 'Radius.Security/secrets')
// Identify direct Radius secret connections from resolved resource metadata.
var isSecretsResource = reduce(items(resourceConnections), {}, (acc, conn) => union(acc, {
'${conn.key}': string(conn.value.?type ?? '') == 'Radius.Security/secrets' || (contains(connectionDefinitions, conn.key) && contains(string(connectionDefinitions[conn.key].?source ?? ''), 'Radius.Security/secrets'))
}))

// When a connection is to Radius.Security/secrets. The K8s secret name is the Radius resource name (last segment of the source ID) or a surfaced secretName property on the connection
var secretsEnvFrom = reduce(items(resourceConnections), [], (acc, conn) =>
connectionDefinitions[conn.key].?disableDefaultEnvVars == true
// Resolved and declared connection maps can briefly differ while dependencies update.
var disableDefaultEnvVars = reduce(items(resourceConnections), {}, (acc, conn) => union(acc, {
'${conn.key}': contains(connectionDefinitions, conn.key)
? connectionDefinitions[conn.key].?disableDefaultEnvVars == true
: false
}))

// A direct Radius.Security/secrets connection injects each declared data key.
var directSecretEnvVars = reduce(items(resourceConnections), [], (acc, conn) =>
disableDefaultEnvVars[conn.key] || !isSecretsResource[conn.key]
? acc
: isSecretsResource[conn.key]
? concat(acc, [{
prefix: toUpper('CONNECTION_${conn.key}_')
secretRef: {
// Extract the secret name from the connection source (last segment of the resource ID)
name: last(split(string(connectionDefinitions[conn.key].source), '/'))
: concat(
acc,
reduce(items(conn.value.?properties.?data ?? {}), [], (envAcc, secret) => concat(envAcc, [{
name: toUpper('CONNECTION_${conn.key}_${secret.key}')
valueFrom: {
secretKeyRef: {
name: last(split(string(connectionDefinitions[conn.key].source), '/'))
key: secret.key
}
}
}])
: acc
}]))
)
)

// Producer Recipe secrets arrive as reference metadata, separate from ordinary properties.
var managedSecretEnvVars = reduce(items(resourceConnections), [], (acc, conn) =>
disableDefaultEnvVars[conn.key] || isSecretsResource[conn.key]
? acc
: concat(
acc,
reduce(items(conn.value.?secrets ?? {}), [], (envAcc, secret) => concat(envAcc, [{
name: toUpper('CONNECTION_${conn.key}_${secret.key}')
valueFrom: {
secretKeyRef: {
name: last(split(string(secret.value.source), '/'))
key: string(secret.value.key)
}
}
}]))
)
)

var secretConnectionEnvVars = concat(directSecretEnvVars, managedSecretEnvVars)
var secretConnectionEnvVarNames = map(secretConnectionEnvVars, envVar => envVar.name)
var validatedSecretConnectionEnvVars = length(secretConnectionEnvVarNames) == length(union(secretConnectionEnvVarNames, secretConnectionEnvVarNames))
? secretConnectionEnvVars
: fail('Connection secret keys must produce unique environment variable names after uppercasing.')

// When a connection has a secretName property, inject all secret keys via envFrom.secretRef
var secretNameEnvFrom = reduce(items(resourceConnections), [], (acc, conn) =>
connectionDefinitions[conn.key].?disableDefaultEnvVars == true
var secretNameEnvFrom = reduce(items(resourceConnections), [], (acc, conn) =>
disableDefaultEnvVars[conn.key]
? acc
: contains(conn.value ?? {}, 'secretName')
? concat(acc, [{
Expand All @@ -83,37 +118,38 @@ var secretNameEnvFrom = reduce(items(resourceConnections), [], (acc, conn) =>
: acc
)

// Each connection's resource properties (including the nested properties bag) become CONNECTION_<CONNECTION_NAME>_<PROPERTY_NAME>
// Ordinary top-level scalar values and nested producer properties become
// CONNECTION_<CONNECTION_NAME>_<PROPERTY_NAME>.
// Null-valued properties are skipped: sensitive properties (e.g. a database
// password marked x-radius-sensitive) are redacted to null on reads, and
// string(null) fails ARM template validation with "InvalidTemplate".
var connectionEnvVars = reduce(items(resourceConnections), [], (acc, conn) =>
// Only process non-secrets connections here (secrets use envFrom)
!isSecretsResource[conn.key] && connectionDefinitions[conn.key].?disableDefaultEnvVars != true
? concat(
var rawConnectionEnvVars = reduce(items(resourceConnections), [], (acc, conn) =>
isSecretsResource[conn.key] || disableDefaultEnvVars[conn.key]
? acc
: concat(
acc,
// Add top-level connection properties (excluding metadata and the nested properties bag)
reduce(items(conn.value ?? {}), [], (envAcc, prop) =>
(prop.key == 'properties' || prop.key == 'secretName' || contains(excludedProperties, prop.key) || prop.value == null)
? envAcc
reduce(items(conn.value ?? {}), [], (envAcc, prop) =>
(contains(topLevelExcludedProperties, prop.key) || prop.value == null)
? envAcc
: concat(envAcc, [{
name: toUpper('CONNECTION_${conn.key}_${prop.key}')
value: string(prop.value)
}])
),
// Flatten the nested connection.properties bag so values like host/port become their own env vars
reduce(items(conn.value.?properties ?? {}), [], (envAcc, prop) =>
reduce(items(conn.value.?properties ?? {}), [], (envAcc, prop) =>
(prop.key == 'secretName' || contains(excludedProperties, prop.key) || prop.value == null)
? envAcc
? envAcc
: concat(envAcc, [{
name: toUpper('CONNECTION_${conn.key}_${prop.key}')
value: string(prop.value)
}])
)
)
: acc
)

// Managed secret references take precedence over an ordinary output with the same name.
var connectionEnvVars = filter(rawConnectionEnvVars, envVar => !contains(secretConnectionEnvVarNames, envVar.name))

// Use replicas from properties, default to 1 if not specified
var replicaCount = resourceProperties.?replicas != null ? int(resourceProperties.replicas) : 1

Expand All @@ -135,7 +171,7 @@ var containerSpecs = reduce(containerItems, [], (acc, item) => concat(acc, [{
} : {},
// Add environment variables from container definition and connections
// Connection environment variables are automatically added from output values
(contains(item.value, 'env') || length(connectionEnvVars) > 0) ? {
(contains(item.value, 'env') || length(connectionEnvVars) > 0 || length(validatedSecretConnectionEnvVars) > 0) ? {
// Kubelet expands $(VAR) in an env var's value ONLY against env vars defined
// earlier in this container's ordered env list. Emit secret/valueFrom env vars
// FIRST so plain value env vars can compose them via $(VAR) without leaking
Expand All @@ -155,23 +191,23 @@ var containerSpecs = reduce(containerItems, [], (acc, item) => concat(acc, [{
}
}])
: envAcc),
// 2. Container-defined plain value env vars (may reference the secrets above).
// 2. Connection secret variables, unless an explicit variable uses the same name.
filter(validatedSecretConnectionEnvVars, envVar => !contains(item.value.?env ?? {}, envVar.name)),
// 3. Container-defined plain value env vars (may reference the secrets above).
reduce(items(item.value.?env ?? {}), [], (envAcc, envItem) =>
contains(envItem.value, 'value')
? concat(envAcc, [{
name: envItem.key
value: envItem.value.value
}])
: envAcc),
// 3. Connection-derived env vars (non-secrets connections)
connectionEnvVars
// 4. Ordinary connection values, unless an explicit variable uses the same name.
filter(connectionEnvVars, envVar => !contains(item.value.?env ?? {}, envVar.name))
)
} : {},
// Add envFrom for secrets connections and secretName-based connections
// secretsEnvFrom: injects all keys from a Radius.Security/secrets source
// secretNameEnvFrom: injects all keys from a K8s secret referenced by secretName property
(length(secretsEnvFrom) > 0 || length(secretNameEnvFrom) > 0) ? {
envFrom: concat(secretsEnvFrom, secretNameEnvFrom)
// Connections that surface secretName still inject that Kubernetes Secret via envFrom.
length(secretNameEnvFrom) > 0 ? {
envFrom: secretNameEnvFrom
} : {},
// Add volume mounts if they exist
contains(item.value, 'volumeMounts') ? {
Expand Down
Loading