Skip to content

Commit

Permalink
feat(opennebula): Use posix shell if bash is unavailable
Browse files Browse the repository at this point in the history
A hard dependency on bash is undesirable for many reasons. Use 'set'
for getting environment variables, rather than bash features.

Unfortunately, bash implements 'set' in a way that is posix-compliant,
but different from dash/ash[1], so this separate implementations must
for bash and supported posix shells must exist.

Note: The choice to make the datasource configuration implementation a bash
      script was highly unusual. Unfortunately the format is in OpenNebula's
      control, not cloud-init's. Still, adding posix shell support to
      cloud-init's implementation is an incremental improvement.

[1] by outputting ansi-c strings - $'' - rather than multi-line strings
  • Loading branch information
holmanb committed Mar 29, 2024
1 parent 95006e7 commit 53c2a06
Show file tree
Hide file tree
Showing 2 changed files with 112 additions and 33 deletions.
141 changes: 110 additions & 31 deletions cloudinit/sources/DataSourceOpenNebula.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import os
import pwd
import re
import shlex
import string

from cloudinit import atomic_helper, net, sources, subp, util
Expand All @@ -27,6 +28,16 @@
DEFAULT_IID = "iid-dsopennebula"
DEFAULT_PARSEUSER = "nobody"
CONTEXT_DISK_FILES = ["context.sh"]
EXCLUDED_VARS = (
"EPOCHREALTIME",
"EPOCHSECONDS",
"RANDOM",
"LINENO",
"SECONDS",
"_",
"SRANDOM",
"__v",
)


class DataSourceOpenNebula(sources.DataSource):
Expand Down Expand Up @@ -299,27 +310,108 @@ def switch_user_cmd(user):
return ["sudo", "-u", user]


def parse_shell_config(
content, keylist=None, bash=None, asuser=None, switch_user_cb=None
):
def parse_shell_config(content, asuser=None):
"""run content and return environment variables which changed
if isinstance(bash, str):
bash = [bash]
elif bash is None:
bash = ["bash", "-e"]
WARNING: the special variable _start_ is used to delimit content
if switch_user_cb is None:
switch_user_cb = switch_user_cmd
a context.sh that defines this variable might break in unexpected
ways
"""
if "_start_" in content:
LOG.warning(
"User defined _start_ variable in context.sh, this may break"
"cloud-init in unexpected ways."
)
if subp.which("bash"):
return parse_shell_config_bash(content, asuser)
else:
return parse_shell_config_posix(content, asuser)


def parse_shell_config_posix(content, asuser=None):
"""run content and return environment variables which changed
compatible with posix shells such as dash and ash
"""

def varprinter():
return "\n".join(
(
'printf "%s\\0" _start_',
"set",
'printf "%s\\0" _start_',
"",
)
)

# the rendered 'bcmd' does:
#
# setup: declare variables we use (so they show up in 'all')
# varprinter(allvars): print all variables known at beginning
# content: execute the provided content
# varprinter(keylist): print all variables known after content
#
# output is then a newline terminated array of:
# [0] unwanted content before first _start_
# [1] key=value (for each preset variable)
# [2] unwanted content between second and third _start_
# [3] key=value (for each post set variable)
bcmd = (
"unset IFS\n"
"__v=''\n"
+ varprinter()
+ "{\n%s\n\n:\n} > /dev/null\n" % content
+ "unset IFS\n"
+ varprinter()
+ "\n"
)

cmd = []
if asuser is not None:
cmd = switch_user_cmd(asuser)
cmd.extend(["sh", "-e"])

output = subp.subp(cmd, data=bcmd).stdout

# exclude vars that change on their own or that we used
ret = {}

# Add to ret only things were changed and not in excluded.
# skip all content before initial _start_\x00 pair
sections = output.split("_start_\x00")[1:]

# store env variables prior to content run
# skip all content before second _start\x00 pair
# store env variables prior to content run
before, after = sections[0], sections[2]

pre_env = dict(
variable.split("=", maxsplit=1) for variable in shlex.split(before)
)
post_env = dict(
variable.split("=", maxsplit=1) for variable in shlex.split(after)
)
for key in set(pre_env.keys()).union(set(post_env.keys())):
if key in EXCLUDED_VARS:
continue
value = post_env.get(key)
if value is not None and value != pre_env.get(key):
ret[key] = value

return ret


def parse_shell_config_bash(content, asuser=None):
"""run content and return environment variables which changed
compatible with bash
"""

# allvars expands to all existing variables by using '${!x*}' notation
# where x is lower or upper case letters or '_'
allvars = ["${!%s*}" % x for x in string.ascii_letters + "_"]

keylist_in = keylist
if keylist is None:
keylist = allvars
keylist_in = []

setup = "\n".join(
(
'__v="";',
Expand Down Expand Up @@ -356,29 +448,18 @@ def varprinter(vlist):
+ varprinter(allvars)
+ "{\n%s\n\n:\n} > /dev/null\n" % content
+ "unset IFS\n"
+ varprinter(keylist)
+ varprinter(allvars)
+ "\n"
)

cmd = []
if asuser is not None:
cmd = switch_user_cb(asuser)

cmd.extend(bash)
cmd = switch_user_cmd(asuser)
cmd.extend(["bash", "-e"])

(output, _error) = subp.subp(cmd, data=bcmd)

# exclude vars in bash that change on their own or that we used
excluded = (
"EPOCHREALTIME",
"EPOCHSECONDS",
"RANDOM",
"LINENO",
"SECONDS",
"_",
"SRANDOM",
"__v",
)
preset = {}
ret = {}
target = None
Expand All @@ -391,9 +472,7 @@ def varprinter(vlist):
(key, val) = line.split("=", 1)
if target is preset:
preset[key] = val
elif key not in excluded and (
key in keylist_in or preset.get(key) != val
):
elif key not in EXCLUDED_VARS and preset.get(key) != val:
ret[key] = val
except ValueError:
if line != "_start_":
Expand Down
4 changes: 2 additions & 2 deletions tests/unittests/sources/test_opennebula.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@

class TestOpenNebulaDataSource(CiTestCase):
parsed_user = None
allowed_subp = ["bash"]
allowed_subp = ["bash", "sh"]

def setUp(self):
super(TestOpenNebulaDataSource, self).setUp()
Expand Down Expand Up @@ -1023,7 +1023,7 @@ def test_multiple_nics(self):


class TestParseShellConfig:
@pytest.mark.allow_subp_for("bash")
@pytest.mark.allow_subp_for("bash", "sh")
def test_no_seconds(self):
cfg = "\n".join(["foo=bar", "SECONDS=2", "xx=foo"])
# we could test 'sleep 2', but that would make the test run slower.
Expand Down

0 comments on commit 53c2a06

Please sign in to comment.