Skip to content

Commit ce74218

Browse files
committed
Implement cloud-config runcmd
If the userdata is of type cloud-config, the runcmd entry can contain multiple entries with commands that will be executed, in the order of their definition. The commands can be given as a string or as an array of strings, the first item being the binary to be executed and the rest being the parameters of that binary. The commands will be aggregated and written into one single shell file, in the order of their definition. On Windows, the file will be executed by the native Windows shell cmd.exe. Example userdata file: runcmd: - 'dir C:\\' - ['echo', '1'] Fixes: #27 Change-Id: Ie307e08f8c4108c7bf9108543cc90b6a7fa2e7ae
1 parent 630345d commit ce74218

6 files changed

Lines changed: 197 additions & 0 deletions

File tree

cloudbaseinit/constant.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,5 @@
4646

4747
CERT_LOCATION_LOCAL_MACHINE = "LocalMachine"
4848
CERT_LOCATION_CURRENT_USER = "CurrentUser"
49+
50+
SCRIPT_HEADER_CMD = 'rem cmd'

cloudbaseinit/osutils/base.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,10 @@ def set_path_admin_acls(self, path):
209209

210210
def take_path_ownership(self, path, username=None):
211211
raise NotImplementedError()
212+
213+
def get_default_script_exec_header(self):
214+
"""File header where the cloud-config runcmd will be aggregated.
215+
216+
Example: `#!/bin/bash` for bash or `rem cmd` for cmd.
217+
"""
218+
raise NotImplementedError()

cloudbaseinit/osutils/windows.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import win32service
3737
import winerror
3838

39+
from cloudbaseinit import constant
3940
from cloudbaseinit import exception
4041
from cloudbaseinit.osutils import base
4142
from cloudbaseinit.utils import classloader
@@ -1742,3 +1743,6 @@ def get_file_version(self, path):
17421743
ls = info['FileVersionLS']
17431744
return (win32api.HIWORD(ms), win32api.LOWORD(ms),
17441745
win32api.HIWORD(ls), win32api.LOWORD(ls))
1746+
1747+
def get_default_script_exec_header(self):
1748+
return constant.SCRIPT_HEADER_CMD

cloudbaseinit/plugins/common/userdataplugins/cloudconfigplugins/factory.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
'cloudconfigplugins.set_hostname.SetHostnamePlugin',
3131
'ntp': 'cloudbaseinit.plugins.common.userdataplugins.'
3232
'cloudconfigplugins.set_ntp.SetNtpPlugin',
33+
'runcmd': 'cloudbaseinit.plugins.common.userdataplugins.'
34+
'cloudconfigplugins.runcmd.RunCmdPlugin',
3335
}
3436

3537

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Copyright 2019 Cloudbase Solutions Srl
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
# not use this file except in compliance with the License. You may obtain
5+
# a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
# License for the specific language governing permissions and limitations
13+
# under the License.
14+
15+
import os
16+
import six
17+
18+
from oslo_log import log as oslo_logging
19+
20+
from cloudbaseinit import exception
21+
from cloudbaseinit.osutils import factory
22+
from cloudbaseinit.plugins.common import execcmd
23+
from cloudbaseinit.plugins.common.userdataplugins.cloudconfigplugins import (
24+
base
25+
)
26+
from cloudbaseinit.plugins.common import userdatautils
27+
28+
LOG = oslo_logging.getLogger(__name__)
29+
30+
31+
class RunCmdPlugin(base.BaseCloudConfigPlugin):
32+
"""Aggregate and execute cloud-config runcmd entries in a shell.
33+
34+
The runcmd entries can be a string or an array of strings.
35+
The prefered shell is given by the OS platform.
36+
37+
Example for Windows, where cmd.exe is the prefered shell:
38+
39+
#cloud-config
40+
41+
runcmd:
42+
- ['dir', 'C:\']
43+
- 'dir C:\'
44+
"""
45+
46+
@staticmethod
47+
def _unify_scripts(commands, env_header):
48+
script_content = env_header + os.linesep
49+
50+
entries = 0
51+
for command in commands:
52+
if isinstance(command, six.string_types):
53+
script_content += command
54+
elif isinstance(command, (list, tuple)):
55+
subcommand_content = []
56+
for subcommand in command:
57+
subcommand_content.append("%s" % subcommand)
58+
script_content += ' '.join(subcommand_content)
59+
else:
60+
raise exception.CloudbaseInitException(
61+
"Unrecognized type '%r' in cmd content" % type(command))
62+
63+
script_content += os.linesep
64+
entries += 1
65+
66+
LOG.info("Found %d cloud-config runcmd entries." % entries)
67+
return script_content
68+
69+
def process(self, data):
70+
if not data:
71+
LOG.info('No cloud-config runcmd entries found.')
72+
return
73+
74+
LOG.info("Running cloud-config runcmd entries.")
75+
osutils = factory.get_os_utils()
76+
env_header = osutils.get_default_script_exec_header()
77+
78+
try:
79+
ret_val = userdatautils.execute_user_data_script(
80+
self._unify_scripts(data, env_header).encode())
81+
_, reboot = execcmd.get_plugin_return_value(ret_val)
82+
return reboot
83+
except Exception as ex:
84+
LOG.warning("An error occurred during runcmd execution: '%s'"
85+
% ex)
86+
return False
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Copyright 2019 Cloudbase Solutions Srl
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
# not use this file except in compliance with the License. You may obtain
5+
# a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
# License for the specific language governing permissions and limitations
13+
# under the License.
14+
15+
import os
16+
import unittest
17+
18+
try:
19+
import unittest.mock as mock
20+
except ImportError:
21+
import mock
22+
23+
from oslo_config import cfg
24+
25+
from cloudbaseinit import exception
26+
from cloudbaseinit.plugins.common.userdataplugins.cloudconfigplugins import (
27+
runcmd
28+
)
29+
30+
from cloudbaseinit.tests import testutils
31+
32+
CONF = cfg.CONF
33+
34+
35+
class RunCmdPluginTest(unittest.TestCase):
36+
37+
def setUp(self):
38+
self._runcmd_plugin = runcmd.RunCmdPlugin()
39+
40+
def test_unify_scripts(self):
41+
run_commands = ['echo 1', 'echo 2']
42+
fake_hader = 'fake_header'
43+
44+
result = self._runcmd_plugin._unify_scripts(run_commands, fake_hader)
45+
46+
ln_sep = os.linesep
47+
expected_result = 'fake_header%secho 1%secho 2%s' % (ln_sep, ln_sep,
48+
ln_sep,)
49+
self.assertEqual(result, expected_result)
50+
51+
def test_unify_scripts_fail(self):
52+
run_commands = [{'cmd': 'fake_cmd'}]
53+
with self.assertRaises(exception.CloudbaseInitException) as cm:
54+
self._runcmd_plugin._unify_scripts(run_commands, 'fake_header')
55+
56+
expected = ("Unrecognized type '%s' in cmd content"
57+
% type(run_commands[0]))
58+
self.assertEqual(expected, str(cm.exception))
59+
60+
@mock.patch('cloudbaseinit.plugins.common.'
61+
'userdatautils.execute_user_data_script')
62+
@mock.patch('cloudbaseinit.osutils.factory.get_os_utils')
63+
def test_process_basic_data(self, mock_os_utils, mock_userdata):
64+
run_commands = ['echo 1', 'echo 2', ['echo', '1'], 'exit 1003']
65+
mock_userdata.return_value = 1003
66+
mock_utils = mock.MagicMock()
67+
mock_utils.get_default_script_exec_header.return_value = 'fake_header'
68+
mock_os_utils.return_value = mock_utils
69+
expected_logging = [
70+
"Running cloud-config runcmd entries.",
71+
"Found 4 cloud-config runcmd entries.",
72+
]
73+
with testutils.LogSnatcher('cloudbaseinit.plugins.common.'
74+
'userdataplugins.cloudconfigplugins.'
75+
'runcmd') as snatcher:
76+
result_process = self._runcmd_plugin.process(run_commands)
77+
78+
mock_utils.get_default_script_exec_header.assert_called_with()
79+
self.assertEqual(expected_logging, snatcher.output)
80+
self.assertEqual(result_process, True)
81+
82+
@mock.patch('cloudbaseinit.osutils.factory.get_os_utils')
83+
def test_process_wrong_cmd_type(self, mock_os_utils):
84+
run_commands = [{'cmd': 'fake_cmd'}]
85+
expected_logging = [
86+
"Running cloud-config runcmd entries.",
87+
"An error occurred during runcmd execution: 'Unrecognized type "
88+
"'%s' in cmd content'" % type(run_commands[0])
89+
]
90+
with testutils.LogSnatcher('cloudbaseinit.plugins.common.'
91+
'userdataplugins.cloudconfigplugins.'
92+
'runcmd') as snatcher:
93+
result_process = self._runcmd_plugin.process(run_commands)
94+
95+
self.assertEqual(expected_logging, snatcher.output)
96+
self.assertEqual(result_process, False)

0 commit comments

Comments
 (0)