summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cloudinit/sources/DataSourceSmartOS.py45
-rw-r--r--cloudinit/util.py72
-rw-r--r--doc/sources/smartos/README.rst92
-rw-r--r--tests/unittests/test_datasource/test_smartos.py145
4 files changed, 322 insertions, 32 deletions
diff --git a/cloudinit/sources/DataSourceSmartOS.py b/cloudinit/sources/DataSourceSmartOS.py
index 6593ce6e..6bd4a5c7 100644
--- a/cloudinit/sources/DataSourceSmartOS.py
+++ b/cloudinit/sources/DataSourceSmartOS.py
@@ -25,7 +25,9 @@
# requests on the console. For example, to get the hostname, you
# would send "GET hostname" on /dev/ttyS1.
#
-
+# Certain behavior is defined by the DataDictionary
+# http://us-east.manta.joyent.com/jmc/public/mdata/datadict.html
+# Comments with "@datadictionary" are snippets of the definition
import base64
from cloudinit import log as logging
@@ -43,10 +45,11 @@ SMARTOS_ATTRIB_MAP = {
'local-hostname': ('hostname', True),
'public-keys': ('root_authorized_keys', True),
'user-script': ('user-script', False),
- 'user-data': ('user-data', False),
+ 'legacy-user-data': ('user-data', False),
+ 'user-data': ('cloud-init:user-data', False),
'iptables_disable': ('iptables_disable', True),
'motd_sys_info': ('motd_sys_info', True),
- 'availability_zone': ('datacenter_name', True),
+ 'availability_zone': ('sdc:datacenter_name', True),
'vendordata': ('sdc:operator-script', False),
}
@@ -71,7 +74,11 @@ BUILTIN_DS_CONFIG = {
'seed_timeout': 60,
'no_base64_decode': ['root_authorized_keys',
'motd_sys_info',
- 'iptables_disable'],
+ 'iptables_disable',
+ 'user-data',
+ 'user-script',
+ 'sdc:datacenter_name',
+ ],
'base64_keys': [],
'base64_all': False,
'disk_aliases': {'ephemeral0': '/dev/vdb'},
@@ -88,6 +95,11 @@ BUILTIN_CLOUD_CONFIG = {
'device': 'ephemeral0'}],
}
+# @datadictionary: this is legacy path for placing files from metadata
+# per the SmartOS location. It is not preferable, but is done for
+# legacy reasons
+LEGACY_USER_D = "/var/db"
+
class DataSourceSmartOS(sources.DataSource):
def __init__(self, sys_cfg, distro, paths):
@@ -107,6 +119,9 @@ class DataSourceSmartOS(sources.DataSource):
self.smartos_no_base64 = self.ds_cfg.get('no_base64_decode')
self.b64_keys = self.ds_cfg.get('base64_keys')
self.b64_all = self.ds_cfg.get('base64_all')
+ self.script_base_d = os.path.join(self.paths.get_cpath("scripts"))
+ self.user_script_d = os.path.join(self.paths.get_cpath("scripts"),
+ 'per-boot')
def __str__(self):
root = sources.DataSource.__str__(self)
@@ -144,14 +159,32 @@ class DataSourceSmartOS(sources.DataSource):
smartos_noun, strip = attribute
md[ci_noun] = self.query(smartos_noun, strip=strip)
+ # @datadictionary: This key has no defined format, but its value
+ # is written to the file /var/db/mdata-user-data on each boot prior
+ # to the phase that runs user-script. This file is not to be executed.
+ # This allows a configuration file of some kind to be injected into
+ # the machine to be consumed by the user-script when it runs.
+ u_script = md.get('user-script')
+ u_script_f = "%s/99_user_script" % self.user_script_d
+ u_script_l = "%s/user-script" % LEGACY_USER_D
+ util.write_content(u_script, u_script_f, link=u_script_l,
+ executable=True)
+
+ # @datadictionary: This key may contain a program that is written
+ # to a file in the filesystem of the guest on each boot and then
+ # executed. It may be of any format that would be considered
+ # executable in the guest instance.
+ u_data = md.get('legacy-user-data')
+ u_data_f = "%s/mdata-user-data" % LEGACY_USER_D
+ util.write_content(u_data, u_data_f)
+
+ # Handle the cloud-init regular meta
if not md['local-hostname']:
md['local-hostname'] = system_uuid
ud = None
if md['user-data']:
ud = md['user-data']
- elif md['user-script']:
- ud = md['user-script']
self.metadata = util.mergemanydict([md, self.metadata])
self.userdata_raw = ud
diff --git a/cloudinit/util.py b/cloudinit/util.py
index 77f9ab36..5f64cb69 100644
--- a/cloudinit/util.py
+++ b/cloudinit/util.py
@@ -1904,3 +1904,75 @@ def expand_dotted_devname(dotted):
return toks
else:
return (dotted, None)
+
+
+def write_executable_content(script, script_f):
+ """
+ This writes executable content and ensures that the shebang
+ exists.
+ """
+ write_file(script_f, script, mode=0700)
+ try:
+ cmd = ["file", "--brief", "--mime-type", script_f]
+ (f_type, _err) = subp(cmd)
+
+ LOG.debug("script %s mime type is %s" % (script_f, f_type))
+
+ # if the magic is text/plain, re-write with the shebang
+ if f_type.strip() == "text/plain":
+ with open(script_f, 'w') as f:
+ f.write("#!/bin/bash\n")
+ f.write(script)
+ LOG.debug("added shebang to file %s" % script_f)
+
+ except ProcessExecutionError as e:
+ logexc(LOG, "Failed to identify script type for %s" % script_f, e)
+ return False
+
+ except IOError as e:
+ logexc(LOG, "Failed to add shebang to file %s" % script_f, e)
+ return False
+
+ return True
+
+
+def write_content(content, content_f, link=None,
+ executable=False):
+ """
+ Write the content to content_f. Under the following rules:
+ 1. Backup previous content_f
+ 2. Write the contente
+ 3. If no content, remove the file
+ 4. If there is a link, create it
+
+ @param content: what to write
+ @param content_f: the file name
+ @param backup_d: the directory to save the backup at
+ @param link: if defined, location to create a symlink to
+ @param executable: is the file executable
+ """
+
+ if content:
+ if not executable:
+ write_file(content_f, content, mode=0400)
+ else:
+ w = write_executable_content(content, content_f)
+ if not w:
+ LOG.debug("failed to write file to %s" % content_f)
+ return False
+
+ if not content and os.path.exists(content_f):
+ os.unlink(content_f)
+
+ if link:
+ try:
+ if os.path.islink(link):
+ os.unlink(link)
+ if content and os.path.exists(content_f):
+ ensure_dir(os.path.dirname(link))
+ os.symlink(content_f, link)
+ except IOError as e:
+ logexc(LOG, "failed establishing content link", e)
+ return False
+
+ return True
diff --git a/doc/sources/smartos/README.rst b/doc/sources/smartos/README.rst
index 8b63e520..e63f311f 100644
--- a/doc/sources/smartos/README.rst
+++ b/doc/sources/smartos/README.rst
@@ -16,11 +16,35 @@ responds with the status and if "SUCCESS" returns until a single ".\n".
New versions of the SmartOS tooling will include support for base64 encoded data.
-Userdata
---------
-
-In SmartOS parlance, user-data is a actually meta-data. This userdata can be
-provided as key-value pairs.
+Meta-data channels
+------------------
+
+Cloud-init supports three modes of delivering user/meta-data via the flexible
+channels of SmartOS.
+
+* user-data is written to /var/db/user-data
+ - per the spec, user-data is for consumption by the end-user, not provisioning
+ tools
+ - cloud-init entirely ignores this channel other than writting it to disk
+ - removal of the meta-data key means that /var/db/user-data gets removed
+ - a backup of previous meta-data is maintained as /var/db/user-data.<timestamp>
+ - <timestamp> is the epoch time when cloud-init ran
+
+* user-script is written to /var/lib/cloud/scripts/per-boot/99_user_data
+ - this is executed each boot
+ - a link is created to /var/db/user-script
+ - previous versions of the user-script is written to
+ /var/lib/cloud/scripts/per-boot.backup/99_user_script.<timestamp>.
+ - <timestamp> is the epoch time when cloud-init ran.
+ - when the 'user-script' meta-data key goes missing, the user-script is
+ removed from the file system, although a backup is maintained.
+ - if the script is not shebanged (i.e. starts with #!<executable>), then
+ or is not an executable, cloud-init will add a shebang of "#!/bin/bash"
+
+* cloud-init:user-data is treated like on other Clouds.
+ - this channel is used for delivering _all_ cloud-init instructions
+ - scripts delivered over this channel must be well formed (i.e. must have
+ a shebang)
Cloud-init supports reading the traditional meta-data fields supported by the
SmartOS tools. These are:
@@ -32,19 +56,49 @@ SmartOS tools. These are:
Note: At this time iptables_disable and enable_motd_sys_info are read but
are not actioned.
-user-script
------------
-
-SmartOS traditionally supports sending over a user-script for execution at the
-rc.local level. Cloud-init supports running user-scripts as if they were
-cloud-init user-data. In this sense, anything with a shell interpreter
-directive will run.
-
-user-data and user-script
--------------------------
-
-In the event that a user defines the meta-data key of "user-data" it will
-always supersede any user-script data. This is for consistency.
+disabling user-script
+---------------------
+
+Cloud-init uses the per-boot script functionality to handle the execution
+of the user-script. If you want to prevent this use a cloud-config of:
+
+#cloud-config
+cloud_final_modules:
+ - scripts-per-once
+ - scripts-per-instance
+ - scripts-user
+ - ssh-authkey-fingerprints
+ - keys-to-console
+ - phone-home
+ - final-message
+ - power-state-change
+
+Alternatively you can use the json patch method
+#cloud-config-jsonp
+[
+ { "op": "replace",
+ "path": "/cloud_final_modules",
+ "value": ["scripts-per-once",
+ "scripts-per-instance",
+ "scripts-user",
+ "ssh-authkey-fingerprints",
+ "keys-to-console",
+ "phone-home",
+ "final-message",
+ "power-state-change"]
+ }
+]
+
+The default cloud-config includes "script-per-boot". Cloud-init will still
+ingest and write the user-data but will not execute it, when you disable
+the per-boot script handling.
+
+Note: Unless you have an explicit use-case, it is recommended that you not
+ disable the per-boot script execution, especially if you are using
+ any of the life-cycle management features of SmartOS.
+
+The cloud-config needs to be delivered over the cloud-init:user-data channel
+in order for cloud-init to ingest it.
base64
------
@@ -54,6 +108,8 @@ are provided by SmartOS:
* root_authorized_keys
* enable_motd_sys_info
* iptables_disable
+ * user-data
+ * user-script
This list can be changed through system config of variable 'no_base64_decode'.
diff --git a/tests/unittests/test_datasource/test_smartos.py b/tests/unittests/test_datasource/test_smartos.py
index 956767d8..ae427bb5 100644
--- a/tests/unittests/test_datasource/test_smartos.py
+++ b/tests/unittests/test_datasource/test_smartos.py
@@ -27,6 +27,10 @@ from cloudinit import helpers
from cloudinit.sources import DataSourceSmartOS
from mocker import MockerTestCase
+import os
+import os.path
+import re
+import stat
import uuid
MOCK_RETURNS = {
@@ -35,7 +39,11 @@ MOCK_RETURNS = {
'disable_iptables_flag': None,
'enable_motd_sys_info': None,
'test-var1': 'some data',
- 'user-data': '\n'.join(['#!/bin/sh', '/bin/true', '']),
+ 'cloud-init:user-data': '\n'.join(['#!/bin/sh', '/bin/true', '']),
+ 'sdc:datacenter_name': 'somewhere2',
+ 'sdc:operator-script': '\n'.join(['bin/true', '']),
+ 'user-data': '\n'.join(['something', '']),
+ 'user-script': '\n'.join(['/bin/true', '']),
}
DMI_DATA_RETURN = (str(uuid.uuid4()), 'smartdc')
@@ -101,6 +109,7 @@ class TestSmartOSDataSource(MockerTestCase):
def setUp(self):
# makeDir comes from MockerTestCase
self.tmp = self.makeDir()
+ self.legacy_user_d = self.makeDir()
# patch cloud_dir, so our 'seed_dir' is guaranteed empty
self.paths = helpers.Paths({'cloud_dir': self.tmp})
@@ -138,6 +147,7 @@ class TestSmartOSDataSource(MockerTestCase):
sys_cfg['datasource'] = sys_cfg.get('datasource', {})
sys_cfg['datasource']['SmartOS'] = ds_cfg
+ self.apply_patches([(mod, 'LEGACY_USER_D', self.legacy_user_d)])
self.apply_patches([(mod, 'get_serial', _get_serial)])
self.apply_patches([(mod, 'dmi_data', _dmi_data)])
dsrc = mod.DataSourceSmartOS(sys_cfg, distro=None,
@@ -194,7 +204,7 @@ class TestSmartOSDataSource(MockerTestCase):
# metadata provided base64_all of true
my_returns = MOCK_RETURNS.copy()
my_returns['base64_all'] = "true"
- for k in ('hostname', 'user-data'):
+ for k in ('hostname', 'cloud-init:user-data'):
my_returns[k] = base64.b64encode(my_returns[k])
dsrc = self._get_ds(mockdata=my_returns)
@@ -202,7 +212,7 @@ class TestSmartOSDataSource(MockerTestCase):
self.assertTrue(ret)
self.assertEquals(MOCK_RETURNS['hostname'],
dsrc.metadata['local-hostname'])
- self.assertEquals(MOCK_RETURNS['user-data'],
+ self.assertEquals(MOCK_RETURNS['cloud-init:user-data'],
dsrc.userdata_raw)
self.assertEquals(MOCK_RETURNS['root_authorized_keys'],
dsrc.metadata['public-keys'])
@@ -213,9 +223,9 @@ class TestSmartOSDataSource(MockerTestCase):
def test_b64_userdata(self):
my_returns = MOCK_RETURNS.copy()
- my_returns['b64-user-data'] = "true"
+ my_returns['b64-cloud-init:user-data'] = "true"
my_returns['b64-hostname'] = "true"
- for k in ('hostname', 'user-data'):
+ for k in ('hostname', 'cloud-init:user-data'):
my_returns[k] = base64.b64encode(my_returns[k])
dsrc = self._get_ds(mockdata=my_returns)
@@ -223,7 +233,8 @@ class TestSmartOSDataSource(MockerTestCase):
self.assertTrue(ret)
self.assertEquals(MOCK_RETURNS['hostname'],
dsrc.metadata['local-hostname'])
- self.assertEquals(MOCK_RETURNS['user-data'], dsrc.userdata_raw)
+ self.assertEquals(MOCK_RETURNS['cloud-init:user-data'],
+ dsrc.userdata_raw)
self.assertEquals(MOCK_RETURNS['root_authorized_keys'],
dsrc.metadata['public-keys'])
@@ -238,13 +249,131 @@ class TestSmartOSDataSource(MockerTestCase):
self.assertTrue(ret)
self.assertEquals(MOCK_RETURNS['hostname'],
dsrc.metadata['local-hostname'])
- self.assertEquals(MOCK_RETURNS['user-data'], dsrc.userdata_raw)
+ self.assertEquals(MOCK_RETURNS['cloud-init:user-data'],
+ dsrc.userdata_raw)
def test_userdata(self):
dsrc = self._get_ds(mockdata=MOCK_RETURNS)
ret = dsrc.get_data()
self.assertTrue(ret)
- self.assertEquals(MOCK_RETURNS['user-data'], dsrc.userdata_raw)
+ self.assertEquals(MOCK_RETURNS['user-data'],
+ dsrc.metadata['legacy-user-data'])
+ self.assertEquals(MOCK_RETURNS['cloud-init:user-data'],
+ dsrc.userdata_raw)
+
+ def test_sdc_scripts(self):
+ dsrc = self._get_ds(mockdata=MOCK_RETURNS)
+ ret = dsrc.get_data()
+ self.assertTrue(ret)
+ self.assertEquals(MOCK_RETURNS['user-script'],
+ dsrc.metadata['user-script'])
+
+ legacy_script_f = "%s/user-script" % self.legacy_user_d
+ self.assertTrue(os.path.exists(legacy_script_f))
+ self.assertTrue(os.path.islink(legacy_script_f))
+ user_script_perm = oct(os.stat(legacy_script_f)[stat.ST_MODE])[-3:]
+ self.assertEquals(user_script_perm, '700')
+
+ def test_scripts_shebanged(self):
+ dsrc = self._get_ds(mockdata=MOCK_RETURNS)
+ ret = dsrc.get_data()
+ self.assertTrue(ret)
+ self.assertEquals(MOCK_RETURNS['user-script'],
+ dsrc.metadata['user-script'])
+
+ legacy_script_f = "%s/user-script" % self.legacy_user_d
+ self.assertTrue(os.path.exists(legacy_script_f))
+ self.assertTrue(os.path.islink(legacy_script_f))
+ shebang = None
+ with open(legacy_script_f, 'r') as f:
+ shebang = f.readlines()[0].strip()
+ self.assertEquals(shebang, "#!/bin/bash")
+ user_script_perm = oct(os.stat(legacy_script_f)[stat.ST_MODE])[-3:]
+ self.assertEquals(user_script_perm, '700')
+
+ def test_scripts_shebang_not_added(self):
+ """
+ Test that the SmartOS requirement that plain text scripts
+ are executable. This test makes sure that plain texts scripts
+ with out file magic have it added appropriately by cloud-init.
+ """
+
+ my_returns = MOCK_RETURNS.copy()
+ my_returns['user-script'] = '\n'.join(['#!/usr/bin/perl',
+ 'print("hi")', ''])
+
+ dsrc = self._get_ds(mockdata=my_returns)
+ ret = dsrc.get_data()
+ self.assertTrue(ret)
+ self.assertEquals(my_returns['user-script'],
+ dsrc.metadata['user-script'])
+
+ legacy_script_f = "%s/user-script" % self.legacy_user_d
+ self.assertTrue(os.path.exists(legacy_script_f))
+ self.assertTrue(os.path.islink(legacy_script_f))
+ shebang = None
+ with open(legacy_script_f, 'r') as f:
+ shebang = f.readlines()[0].strip()
+ self.assertEquals(shebang, "#!/usr/bin/perl")
+
+ def test_scripts_removed(self):
+ """
+ Since SmartOS requires that the user script is fetched
+ each boot, we want to make sure that the information
+ is backed-up for user-review later.
+
+ This tests the behavior of when a script is removed. It makes
+ sure that a) the previous script is backed-up; and 2) that
+ there is no script remaining.
+ """
+
+ script_d = os.path.join(self.tmp, "scripts", "per-boot")
+ os.makedirs(script_d)
+
+ test_script_f = "%s/99_user_script" % script_d
+ with open(test_script_f, 'w') as f:
+ f.write("TEST DATA")
+
+ my_returns = MOCK_RETURNS.copy()
+ del my_returns['user-script']
+
+ dsrc = self._get_ds(mockdata=my_returns)
+ ret = dsrc.get_data()
+ self.assertTrue(ret)
+ self.assertFalse(dsrc.metadata['user-script'])
+ self.assertFalse(os.path.exists(test_script_f))
+
+ def test_userdata_removed(self):
+ """
+ User-data in the SmartOS world is supposed to be written to a file
+ each and every boot. This tests to make sure that in the event the
+ legacy user-data is removed, the existing user-data is backed-up and
+ there is no /var/db/user-data left.
+ """
+
+ user_data_f = "%s/mdata-user-data" % self.legacy_user_d
+ with open(user_data_f, 'w') as f:
+ f.write("PREVIOUS")
+
+ my_returns = MOCK_RETURNS.copy()
+ del my_returns['user-data']
+
+ dsrc = self._get_ds(mockdata=my_returns)
+ ret = dsrc.get_data()
+ self.assertTrue(ret)
+ self.assertFalse(dsrc.metadata.get('legacy-user-data'))
+
+ found_new = False
+ for root, _dirs, files in os.walk(self.legacy_user_d):
+ for name in files:
+ name_f = os.path.join(root, name)
+ permissions = oct(os.stat(name_f)[stat.ST_MODE])[-3:]
+ if re.match(r'.*\/mdata-user-data$', name_f):
+ found_new = True
+ print name_f
+ self.assertEquals(permissions, '400')
+
+ self.assertFalse(found_new)
def test_disable_iptables_flag(self):
dsrc = self._get_ds(mockdata=MOCK_RETURNS)