Skip to content

Commit 3d7ef6d

Browse files
Merge pull request #192 from JonathonReinhart/191-expand-vars-in-volumes
Expand env variables in volume paths in configuration file
2 parents 04dc2c2 + 2405ed8 commit 3d7ef6d

File tree

6 files changed

+133
-2
lines changed

6 files changed

+133
-2
lines changed

docs/configuration.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,19 @@ of volume options:
166166
hostpath: /host/foo
167167
options: ro,cached
168168
169+
The keys used in volume mappings can contain environment variables **that are
170+
expanded in the host environment**. For example, this configuration would map
171+
the user's ``/home/username/.config/application1`` directory into the container
172+
at the same path.
173+
174+
.. code-block:: yaml
175+
176+
volumes:
177+
$TEST_HOME/.config/application1: $TEST_HOME/.config/application1
178+
179+
Note that because variable expansion is now applied to all volume keys, if one
180+
desires to have a key with an explicit ``$`` character, it must be written as
181+
``$$``.
169182

170183

171184
.. _conf_aliases:

scuba/config.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,9 +261,22 @@ def _get_volumes(data):
261261

262262
vols = {}
263263
for cpath, v in voldata.items():
264+
cpath = _expand_path(cpath)
264265
vols[cpath] = ScubaVolume.from_dict(cpath, v)
265266
return vols
266267

268+
def _expand_path(in_str):
269+
try:
270+
output = expand_env_vars(in_str)
271+
except KeyError as ke:
272+
# pylint: disable=raise-missing-from
273+
raise ConfigError("Unset environment variable '{}' used in '{}'".format(ke.args[0], in_str))
274+
except ValueError as ve:
275+
raise ConfigError("Unable to expand string '{}' due to parsing "
276+
"errors".format(in_str)) from ve
277+
278+
return output
279+
267280
class ScubaVolume:
268281
def __init__(self, container_path, host_path=None, options=None):
269282
self.container_path = container_path
@@ -282,7 +295,7 @@ def from_dict(cls, cpath, node):
282295
if isinstance(node, str):
283296
return cls(
284297
container_path = cpath,
285-
host_path = node,
298+
host_path = _expand_path(node),
286299
)
287300

288301
# Complex form
@@ -296,7 +309,7 @@ def from_dict(cls, cpath, node):
296309
raise ConfigError("Volume {} must have a 'hostpath' subkey".format(cpath))
297310
return cls(
298311
container_path = cpath,
299-
host_path = hpath,
312+
host_path = _expand_path(hpath),
300313
options = _get_delimited_str_list(node, 'options', ','),
301314
)
302315

scuba/utils.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import errno
22
import os
33
from shlex import quote as shell_quote
4+
import string
45

56

67
def shell_quote_cmd(cmdlist):
@@ -79,3 +80,10 @@ def get_umask():
7980

8081
def writeln(f, line):
8182
f.write(line + '\n')
83+
84+
def expand_env_vars(in_str):
85+
"""Expand environment variables in a string
86+
87+
Can raise `KeyError` if a variable is referenced but not defined, similar to
88+
bash's nounset (set -u) option"""
89+
return string.Template(in_str).substitute(os.environ)

tests/test_config.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -883,3 +883,76 @@ def test_alias_volumes_set(self):
883883
assert v.container_path == '/bar'
884884
assert v.host_path == '/host/bar'
885885
assert v.options == ['z', 'ro']
886+
887+
def test_volumes_with_env_vars_simple(self, monkeypatch):
888+
'''volume definitions can contain environment variables'''
889+
monkeypatch.setenv("TEST_VOL_PATH", "/bar/baz")
890+
monkeypatch.setenv("TEST_VOL_PATH2", "/moo/doo")
891+
with open('.scuba.yml', 'w') as f:
892+
f.write(r'''
893+
image: na
894+
volumes:
895+
$TEST_VOL_PATH/foo: ${TEST_VOL_PATH2}/foo
896+
''')
897+
898+
config = scuba.config.load_config('.scuba.yml')
899+
vols = config.volumes
900+
assert len(vols) == 1
901+
902+
v = list(vols.values())[0]
903+
assert isinstance(v, scuba.config.ScubaVolume)
904+
assert v.container_path == '/bar/baz/foo'
905+
assert v.host_path == '/moo/doo/foo'
906+
assert v.options == []
907+
908+
def test_volumes_with_env_vars_complex(self, monkeypatch):
909+
'''complex volume definitions can contain environment variables'''
910+
monkeypatch.setenv("TEST_HOME", "/home/testuser")
911+
monkeypatch.setenv("TEST_TMP", "/tmp")
912+
monkeypatch.setenv("TEST_MAIL", "/var/spool/mail/testuser")
913+
914+
with open('.scuba.yml', 'w') as f:
915+
f.write(r'''
916+
image: na
917+
volumes:
918+
$TEST_HOME/.config: ${TEST_HOME}/.config
919+
$TEST_TMP/:
920+
hostpath: $TEST_HOME/scuba/myproject/tmp
921+
/var/spool/mail/container:
922+
hostpath: $TEST_MAIL
923+
options: z,ro
924+
''')
925+
926+
config = scuba.config.load_config('.scuba.yml')
927+
vols = config.volumes
928+
assert len(vols) == 3
929+
930+
v = vols['/home/testuser/.config']
931+
assert isinstance(v, scuba.config.ScubaVolume)
932+
assert v.container_path == '/home/testuser/.config'
933+
assert v.host_path == '/home/testuser/.config'
934+
assert v.options == []
935+
936+
v = vols['/tmp/']
937+
assert isinstance(v, scuba.config.ScubaVolume)
938+
assert v.container_path == '/tmp/'
939+
assert v.host_path == '/home/testuser/scuba/myproject/tmp'
940+
assert v.options == []
941+
942+
v = vols['/var/spool/mail/container']
943+
assert isinstance(v, scuba.config.ScubaVolume)
944+
assert v.container_path == '/var/spool/mail/container'
945+
assert v.host_path == "/var/spool/mail/testuser"
946+
assert v.options == ['z', 'ro']
947+
948+
def test_volumes_with_invalid_env_vars(self, monkeypatch):
949+
'''Volume definitions cannot include unset env vars'''
950+
# Ensure that the entry does not exist in the environment
951+
monkeypatch.delenv("TEST_VAR1", raising=False)
952+
with open('.scuba.yml', 'w') as f:
953+
f.write(r'''
954+
image: na
955+
volumes:
956+
$TEST_VAR1/foo: /host/foo
957+
''')
958+
self._invalid_config('TEST_VAR1')

tests/test_main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .utils import *
22
from unittest import mock
33
import pytest
4+
import warnings
45

56
import logging
67
import os

tests/test_utils.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,26 @@ def test_writeln():
128128
scuba.utils.writeln(s, 'hello')
129129
scuba.utils.writeln(s, 'goodbye')
130130
assert s.getvalue() == 'hello\ngoodbye\n'
131+
132+
def test_expand_env_vars(monkeypatch):
133+
monkeypatch.setenv("MY_VAR", "my favorite variable")
134+
assert scuba.utils.expand_env_vars("This is $MY_VAR") == \
135+
"This is my favorite variable"
136+
assert scuba.utils.expand_env_vars("What is ${MY_VAR}?") == \
137+
"What is my favorite variable?"
138+
139+
def test_expand_missing_env_vars(monkeypatch):
140+
monkeypatch.delenv("MY_VAR", raising=False)
141+
# Verify that a KeyError is raised for unset env variables
142+
with pytest.raises(KeyError) as kerr:
143+
scuba.utils.expand_env_vars("Where is ${MY_VAR}?")
144+
assert kerr.value.args[0] == "MY_VAR"
145+
146+
147+
def test_expand_env_vars_dollars():
148+
# Verify that a ValueError is raised for bare, unescaped '$' characters
149+
with pytest.raises(ValueError):
150+
scuba.utils.expand_env_vars("Just a lonely $")
151+
152+
# Verify that it is possible to get '$' characters in an expanded string
153+
assert scuba.utils.expand_env_vars(r"Just a lonely $$") == "Just a lonely $"

0 commit comments

Comments
 (0)