Implement environment variable interpolation to YAML dictionary keys

`podman-compose` currently does not support interpolating environment
variables in dictionary keys, despite the compose file specification
indicating this capability.
See the relevant compose-spec documentation:
https://github.com/compose-spec/compose-spec/blob/main/12-interpolation.md

This feature is useful in `labels` or `environment` sections, where keys
can be user-defined strings. To enable interpolation, an alternate equal
sign syntax must be used, e.g.:
services:
  foo:
    labels:
      - "$VAR_NAME=label_value"

After this PR `podman-compose` will align more closely with the compose
file specification, allowing for the interpolation of environment
variables in dictionary keys.

Signed-off-by: Monika Kairaityte <monika@kibit.lt>
This commit is contained in:
Monika Kairaityte
2025-06-30 23:36:42 +03:00
parent 9fe6e7f284
commit e97d446a04
6 changed files with 53 additions and 1 deletions

View File

@ -0,0 +1 @@
Add support for environment variable interpolation for YAML keys.

View File

@ -290,7 +290,7 @@ def rec_subs(value: dict | str | Iterable, subs_dict: dict[str, Any]) -> dict |
svc_envs = rec_subs(svc_envs, subs_dict)
subs_dict.update(svc_envs)
value = {k: rec_subs(v, subs_dict) for k, v in value.items()}
value = {rec_subs(k, subs_dict): rec_subs(v, subs_dict) for k, v in value.items()}
elif isinstance(value, str):
def convert(m: re.Match) -> str:

View File

@ -1 +1,2 @@
DOT_ENV_VARIABLE=This value is from the .env file
TEST_LABELS=TEST

View File

@ -11,4 +11,10 @@ services:
EXAMPLE_DOT_ENV: $DOT_ENV_VARIABLE
EXAMPLE_LITERAL: This is a $$literal
EXAMPLE_EMPTY: $NOT_A_VARIABLE
labels_test:
image: busybox
labels:
- "$TEST_LABELS=test_labels"
- test.${TEST_LABELS}=${TEST_LABELS}
- "${TEST_LABELS}.test2=test2(`${TEST_LABELS}`)"

View File

@ -1,5 +1,6 @@
# SPDX-License-Identifier: GPL-2.0
import json
import os
import unittest
@ -36,6 +37,18 @@ class TestComposeInterpolation(unittest.TestCase, RunSubprocessMixin):
self.assertIn("EXAMPLE_DOT_ENV='This value is from the .env file'", str(output))
self.assertIn("EXAMPLE_EMPTY=''", str(output))
self.assertIn("EXAMPLE_LITERAL='This is a $literal'", str(output))
output, _ = self.run_subprocess_assert_returncode([
"podman",
"inspect",
"interpolation_labels_test_1",
])
inspect_output = json.loads(output)
labels_dict = inspect_output[0].get("Config", {}).get("Labels", {})
self.assertIn(('TEST', 'test_labels'), labels_dict.items())
self.assertIn(('TEST.test2', 'test2(`TEST`)'), labels_dict.items())
self.assertIn(('test.TEST', 'TEST'), labels_dict.items())
finally:
self.run_subprocess_assert_returncode([
podman_compose_path(),

View File

@ -71,3 +71,34 @@ class TestRecSubs(unittest.TestCase):
sub_dict = {"v1": "high priority", "empty": ""}
result = rec_subs(input, sub_dict)
self.assertEqual(result, expected, msg=desc)
def test_env_var_substitution_in_dictionary_keys(self) -> None:
sub_dict = {"NAME": "TEST1", "NAME2": "TEST2"}
input = {
'services': {
'test': {
'image': 'busybox',
'labels': {
'$NAME and ${NAME2}': '${NAME2} and $NAME',
'test1.${NAME}': 'test1',
'$NAME': '${NAME2}',
'${NAME}.test2': 'Host(`${NAME2}`)',
},
}
}
}
result = rec_subs(input, sub_dict)
expected = {
'services': {
'test': {
'image': 'busybox',
'labels': {
'TEST1 and TEST2': 'TEST2 and TEST1',
'test1.TEST1': 'test1',
'TEST1': 'TEST2',
'TEST1.test2': 'Host(`TEST2`)',
},
}
}
}
self.assertEqual(result, expected)