diff --git a/etc/heat/heat.conf.sample b/etc/heat/heat.conf.sample index 212e071179..34e3d22cd7 100644 --- a/etc/heat/heat.conf.sample +++ b/etc/heat/heat.conf.sample @@ -14,7 +14,11 @@ # Options defined in heat.common.config # -# The default user for new instances. (string value) +# The default user for new instances. This option is +# deprecated and will be removed in the Juno release. If it's +# empty, Heat will use the default user set up with your cloud +# image (for OS::Nova::Server) or 'ec2-user' (for +# AWS::EC2::Instance). (string value) #instance_user=ec2-user # Driver to use for controlling instances. (string value) diff --git a/heat/cloudinit/boothook.sh b/heat/cloudinit/boothook.sh index 0b8b219f8d..a4b71329ad 100755 --- a/heat/cloudinit/boothook.sh +++ b/heat/cloudinit/boothook.sh @@ -1,11 +1,14 @@ #!/bin/bash -# FIXME(shadower) The `useradd` and `sudoers` lines are a workaround for -# cloud-init 0.6.3 present in Ubuntu 12.04 LTS: +# FIXME(shadower) this is a workaround for cloud-init 0.6.3 present in Ubuntu +# 12.04 LTS: # https://bugs.launchpad.net/heat/+bug/1257410 -# Once we drop support for it, we can safely remove them. -useradd -m @INSTANCE_USER@ -echo -e '@INSTANCE_USER@\tALL=(ALL)\tNOPASSWD: ALL' >> /etc/sudoers +# +# The old cloud-init doesn't create the users directly so the commands to do +# this are injected though nova_utils.py. +# +# Once we drop support for 0.6.3, we can safely remove this. +${add_custom_user} # in case heat-cfntools has been installed from package but no symlinks # are yet in /opt/aws/bin/ diff --git a/heat/cloudinit/config b/heat/cloudinit/config index 555900881c..d9471a0bd8 100644 --- a/heat/cloudinit/config +++ b/heat/cloudinit/config @@ -1,8 +1,4 @@ -# Set the SSH key provided by Nova to this user. -# On cloud-init 0.7.x (anything except Ubuntu 12.04 LTS which ships 0.6.3) this -# also creates the user and sets up passwordless sudo if the user isn't present -# already. -user: @INSTANCE_USER@ +${add_custom_user} # Capture all subprocess output into a logfile # Useful for troubleshooting cloud-init issues diff --git a/heat/common/config.py b/heat/common/config.py index d1065c781a..922014faeb 100644 --- a/heat/common/config.py +++ b/heat/common/config.py @@ -28,6 +28,8 @@ from heat.common import wsgi from heat.openstack.common import log as logging from heat.openstack.common import rpc +logger = logging.getLogger(__name__) + paste_deploy_group = cfg.OptGroup('paste_deploy') paste_deploy_opts = [ cfg.StrOpt('flavor', @@ -80,7 +82,11 @@ service_opts = [ engine_opts = [ cfg.StrOpt('instance_user', default='ec2-user', - help='The default user for new instances.'), + help="The default user for new instances. This option " + "is deprecated and will be removed in the Juno release. " + "If it's empty, Heat will use the default user set up " + "with your cloud image (for OS::Nova::Server) or " + "'ec2-user' (for AWS::EC2::Instance)."), cfg.StrOpt('instance_driver', default='heat.engine.nova', help='Driver to use for controlling instances.'), @@ -213,6 +219,10 @@ allowed_rpc_exception_modules.append('heat.common.exception') cfg.CONF.set_default(name='allowed_rpc_exception_modules', default=allowed_rpc_exception_modules) +if cfg.CONF.instance_user: + logger.warn(_('The "instance_user" option in heat.conf is deprecated and ' + 'will be removed in the Juno release.')) + def _get_deployment_flavor(): """ diff --git a/heat/engine/resources/instance.py b/heat/engine/resources/instance.py index 48f29dfd6d..3020dd88b0 100644 --- a/heat/engine/resources/instance.py +++ b/heat/engine/resources/instance.py @@ -12,6 +12,10 @@ # License for the specific language governing permissions and limitations # under the License. +from oslo.config import cfg + +cfg.CONF.import_opt('instance_user', 'heat.common.config') + from heat.common import exception from heat.engine import clients from heat.engine import constraints @@ -430,6 +434,14 @@ class Instance(resource.Resource): subnet_id=self.properties[self.SUBNET_ID]) server = None + # FIXME(shadower): the instance_user config option is deprecated. Once + # it's gone, we should always use ec2-user for compatibility with + # CloudFormation. + if cfg.CONF.instance_user: + instance_user = cfg.CONF.instance_user + else: + instance_user = 'ec2-user' + try: server = self.nova().servers.create( name=self.physical_resource_name(), @@ -437,7 +449,8 @@ class Instance(resource.Resource): flavor=flavor_id, key_name=self.properties[self.KEY_NAME], security_groups=security_groups, - userdata=nova_utils.build_userdata(self, userdata), + userdata=nova_utils.build_userdata(self, userdata, + instance_user), meta=self._get_nova_metadata(self.properties), scheduler_hints=scheduler_hints, nics=nics, diff --git a/heat/engine/resources/nova_utils.py b/heat/engine/resources/nova_utils.py index 32070d7cbf..d011d0db2c 100644 --- a/heat/engine/resources/nova_utils.py +++ b/heat/engine/resources/nova_utils.py @@ -19,6 +19,7 @@ from email.mime.text import MIMEText import json import os import pkgutil +import string from oslo.config import cfg import six @@ -187,16 +188,33 @@ def build_userdata(resource, userdata=None, instance_user=None, return msg def read_cloudinit_file(fn): - data = pkgutil.get_data('heat', 'cloudinit/%s' % fn) - data = data.replace('@INSTANCE_USER@', - instance_user or cfg.CONF.instance_user) - return data + return pkgutil.get_data('heat', 'cloudinit/%s' % fn) - attachments = [(read_cloudinit_file('config'), 'cloud-config'), - (read_cloudinit_file('boothook.sh'), 'boothook.sh', - 'cloud-boothook')] - attachments.append((read_cloudinit_file('part_handler.py'), - 'part-handler.py')) + if instance_user: + config_custom_user = 'user: %s' % instance_user + # FIXME(shadower): compatibility workaround for cloud-init 0.6.3. We + # can drop this once we stop supporting 0.6.3 (which ships with Ubuntu + # 12.04 LTS). + # + # See bug https://bugs.launchpad.net/heat/+bug/1257410 + boothook_custom_user = r"""useradd -m %s +echo -e '%s\tALL=(ALL)\tNOPASSWD: ALL' >> /etc/sudoers +""" % (instance_user, instance_user) + else: + config_custom_user = '' + boothook_custom_user = '' + + cloudinit_config = string.Template( + read_cloudinit_file('config')).safe_substitute( + add_custom_user=config_custom_user) + cloudinit_boothook = string.Template( + read_cloudinit_file('boothook.sh')).safe_substitute( + add_custom_user=boothook_custom_user) + + attachments = [(cloudinit_config, 'cloud-config'), + (cloudinit_boothook, 'boothook.sh', 'cloud-boothook'), + (read_cloudinit_file('part_handler.py'), + 'part-handler.py')] if is_cfntools: attachments.append((userdata, 'cfn-userdata', 'x-cfninitdata')) diff --git a/heat/engine/resources/server.py b/heat/engine/resources/server.py index a6e61b58ef..82e90c9c7a 100644 --- a/heat/engine/resources/server.py +++ b/heat/engine/resources/server.py @@ -171,8 +171,12 @@ class Server(stack_user.StackUser): ), ADMIN_USER: properties.Schema( properties.Schema.STRING, - _('Name of the administrative user to use on the server.'), - default=cfg.CONF.instance_user + _('Name of the administrative user to use on the server. ' + 'This property will be removed from Juno in favor of the ' + 'default cloud-init user set up for each image (e.g. "ubuntu" ' + 'for Ubuntu 12.04+, "fedora" for Fedora 19+ and "cloud-user" ' + 'for CentOS/RHEL 6.5).'), + support_status=support.SupportStatus(status=support.DEPRECATED) ), AVAILABILITY_ZONE: properties.Schema( properties.Schema.STRING, @@ -470,10 +474,17 @@ class Server(stack_user.StackUser): if self.user_data_software_config(): self._create_transport_credentials() + if self.properties[self.ADMIN_USER]: + instance_user = self.properties[self.ADMIN_USER] + elif cfg.CONF.instance_user: + instance_user = cfg.CONF.instance_user + else: + instance_user = None + userdata = nova_utils.build_userdata( self, ud_content, - instance_user=self.properties[self.ADMIN_USER], + instance_user=instance_user, user_data_format=user_data_format) flavor = self.properties[self.FLAVOR] diff --git a/heat/tests/test_engine_service.py b/heat/tests/test_engine_service.py index 70a784898d..f31d4bc81c 100644 --- a/heat/tests/test_engine_service.py +++ b/heat/tests/test_engine_service.py @@ -195,11 +195,13 @@ def setup_mocks(mocks, stack): instance = stack['WebServer'] user_data = instance.properties['UserData'] - server_userdata = nova_utils.build_userdata(instance, user_data) + server_userdata = nova_utils.build_userdata(instance, user_data, + 'ec2-user') mocks.StubOutWithMock(nova_utils, 'build_userdata') nova_utils.build_userdata( instance, - instance.t['Properties']['UserData']).AndReturn(server_userdata) + instance.t['Properties']['UserData'], + 'ec2-user').AndReturn(server_userdata) mocks.StubOutWithMock(fc.servers, 'create') fc.servers.create(image=744, flavor=3, key_name='test', diff --git a/heat/tests/test_instance.py b/heat/tests/test_instance.py index aa4db56525..3923378c9e 100644 --- a/heat/tests/test_instance.py +++ b/heat/tests/test_instance.py @@ -93,16 +93,6 @@ class InstancesTest(HeatTestCase): instance.t = instance.stack.resolve_runtime_data(instance.t) if stub_create: - # need to resolve the template functions - server_userdata = nova_utils.build_userdata( - instance, - instance.t['Properties']['UserData']) - self.m.StubOutWithMock(nova_utils, 'build_userdata') - nova_utils.build_userdata( - instance, - instance.t['Properties']['UserData']).AndReturn( - server_userdata) - self.m.StubOutWithMock(self.fc.servers, 'create') self.fc.servers.create( image=1, flavor=1, key_name='test', @@ -111,7 +101,7 @@ class InstancesTest(HeatTestCase): instance.name, limit=instance.physical_resource_name_limit), security_groups=None, - userdata=server_userdata, scheduler_hints=None, + userdata=mox.IgnoreArg(), scheduler_hints=None, meta=None, nics=None, availability_zone=None).AndReturn( return_server) @@ -855,3 +845,50 @@ class InstancesTest(HeatTestCase): 'wo_ipaddr') self.assertEqual('0.0.0.0', instance.FnGetAtt('PrivateIp')) + + def test_default_instance_user(self): + """The default value for instance_user in heat.conf is ec2-user.""" + return_server = self.fc.servers.list()[1] + instance = self._setup_test_instance(return_server, 'default_user') + self.m.StubOutWithMock(nova_utils, 'build_userdata') + nova_utils.build_userdata(instance, 'wordpress', 'ec2-user') + self.m.ReplayAll() + scheduler.TaskRunner(instance.create)() + self.m.VerifyAll() + + def test_custom_instance_user(self): + """Test instance_user in heat.conf being set to a custom value. + + Launching the instance should call build_userdata with the custom user + name. + + This option is deprecated and will be removed in Juno. + """ + return_server = self.fc.servers.list()[1] + instance = self._setup_test_instance(return_server, 'custom_user') + self.m.StubOutWithMock(instances.cfg.CONF, 'instance_user') + instances.cfg.CONF.instance_user = 'custom_user' + self.m.StubOutWithMock(nova_utils, 'build_userdata') + nova_utils.build_userdata(instance, 'wordpress', 'custom_user') + self.m.ReplayAll() + scheduler.TaskRunner(instance.create)() + self.m.VerifyAll() + + def test_empty_instance_user(self): + """Test instance_user in heat.conf being empty. + + Launching the instance should call build_userdata with + "ec2-user". + + This behaviour is compatible with CloudFormation and will be + the default in Juno once the instance_user option gets removed. + """ + return_server = self.fc.servers.list()[1] + instance = self._setup_test_instance(return_server, 'empty_user') + self.m.StubOutWithMock(instances.cfg.CONF, 'instance_user') + instances.cfg.CONF.instance_user = '' + self.m.StubOutWithMock(nova_utils, 'build_userdata') + nova_utils.build_userdata(instance, 'wordpress', 'ec2-user') + self.m.ReplayAll() + scheduler.TaskRunner(instance.create)() + self.m.VerifyAll() diff --git a/heat/tests/test_instance_network.py b/heat/tests/test_instance_network.py index dfdd62b3e1..5b16fa4a97 100644 --- a/heat/tests/test_instance_network.py +++ b/heat/tests/test_instance_network.py @@ -178,11 +178,13 @@ class instancesTest(HeatTestCase): # need to resolve the template functions server_userdata = nova_utils.build_userdata( instance, - instance.t['Properties']['UserData']) + instance.t['Properties']['UserData'], + 'ec2-user') self.m.StubOutWithMock(nova_utils, 'build_userdata') nova_utils.build_userdata( instance, - instance.t['Properties']['UserData']).AndReturn(server_userdata) + instance.t['Properties']['UserData'], + 'ec2-user').AndReturn(server_userdata) self.m.StubOutWithMock(self.fc.servers, 'create') self.fc.servers.create( @@ -232,11 +234,13 @@ class instancesTest(HeatTestCase): # need to resolve the template functions server_userdata = nova_utils.build_userdata( instance, - instance.t['Properties']['UserData']) + instance.t['Properties']['UserData'], + 'ec2-user') self.m.StubOutWithMock(nova_utils, 'build_userdata') nova_utils.build_userdata( instance, - instance.t['Properties']['UserData']).AndReturn(server_userdata) + instance.t['Properties']['UserData'], + 'ec2-user').AndReturn(server_userdata) self.m.StubOutWithMock(self.fc.servers, 'create') self.fc.servers.create( diff --git a/heat/tests/test_nokey.py b/heat/tests/test_nokey.py index 4a8aa3da1b..36d970e87e 100644 --- a/heat/tests/test_nokey.py +++ b/heat/tests/test_nokey.py @@ -68,11 +68,13 @@ class nokeyTest(HeatTestCase): # need to resolve the template functions server_userdata = nova_utils.build_userdata( instance, - instance.t['Properties']['UserData']) + instance.t['Properties']['UserData'], + 'ec2-user') self.m.StubOutWithMock(nova_utils, 'build_userdata') nova_utils.build_userdata( instance, - instance.t['Properties']['UserData']).AndReturn(server_userdata) + instance.t['Properties']['UserData'], + 'ec2-user').AndReturn(server_userdata) self.m.StubOutWithMock(self.fc.servers, 'create') self.fc.servers.create( diff --git a/heat/tests/test_nova_utils.py b/heat/tests/test_nova_utils.py index babc4da16f..3e382ba917 100644 --- a/heat/tests/test_nova_utils.py +++ b/heat/tests/test_nova_utils.py @@ -161,17 +161,6 @@ class NovaUtilsRefreshServerTests(HeatTestCase): class NovaUtilsUserdataTests(HeatTestCase): - scenarios = [ - ('no_conf_no_prop', dict( - conf_user='ec2-user', instance_user=None, expect='ec2-user')), - ('no_conf_prop', dict( - conf_user='ec2-user', instance_user='fruity', expect='fruity')), - ('conf_no_prop', dict( - conf_user='nutty', instance_user=None, expect='nutty')), - ('conf_prop', dict( - conf_user='nutty', instance_user='fruity', expect='fruity')), - ] - def setUp(self): super(NovaUtilsUserdataTests, self).setUp() self.nova_client = self.m.CreateMockAnything() @@ -182,14 +171,12 @@ class NovaUtilsUserdataTests(HeatTestCase): resource.metadata = {} self.m.StubOutWithMock(nova_utils.cfg, 'CONF') cnf = nova_utils.cfg.CONF - cnf.instance_user = self.conf_user cnf.heat_metadata_server_url = 'http://server.test:123' cnf.heat_watch_server_url = 'http://server.test:345' cnf.instance_connection_is_secure = False cnf.instance_connection_https_validate_certificates = False self.m.ReplayAll() - data = nova_utils.build_userdata(resource, - instance_user=self.instance_user) + data = nova_utils.build_userdata(resource) self.assertIn("Content-Type: text/cloud-config;", data) self.assertIn("Content-Type: text/cloud-boothook;", data) self.assertIn("Content-Type: text/part-handler;", data) @@ -198,7 +185,38 @@ class NovaUtilsUserdataTests(HeatTestCase): self.assertIn("http://server.test:345", data) self.assertIn("http://server.test:123", data) self.assertIn("[Boto]", data) - self.assertIn(self.expect, data) + self.m.VerifyAll() + + def test_build_userdata_without_instance_user(self): + """Don't add a custom instance user when not requested.""" + resource = self.m.CreateMockAnything() + resource.metadata = {} + self.m.StubOutWithMock(nova_utils.cfg, 'CONF') + cnf = nova_utils.cfg.CONF + cnf.instance_user = 'config_instance_user' + cnf.heat_metadata_server_url = 'http://server.test:123' + cnf.heat_watch_server_url = 'http://server.test:345' + self.m.ReplayAll() + data = nova_utils.build_userdata(resource, instance_user=None) + self.assertNotIn('user: ', data) + self.assertNotIn('useradd', data) + self.assertNotIn('config_instance_user', data) + self.m.VerifyAll() + + def test_build_userdata_with_instance_user(self): + """Add the custom instance user when requested.""" + resource = self.m.CreateMockAnything() + resource.metadata = {} + self.m.StubOutWithMock(nova_utils.cfg, 'CONF') + cnf = nova_utils.cfg.CONF + cnf.instance_user = 'config_instance_user' + cnf.heat_metadata_server_url = 'http://server.test:123' + cnf.heat_watch_server_url = 'http://server.test:345' + self.m.ReplayAll() + data = nova_utils.build_userdata(resource, + instance_user="custominstanceuser") + self.assertNotIn('config_instance_user', data) + self.assertIn("custominstanceuser", data) self.m.VerifyAll() diff --git a/heat/tests/test_server.py b/heat/tests/test_server.py index 6a80f700bb..0d4d18890b 100644 --- a/heat/tests/test_server.py +++ b/heat/tests/test_server.py @@ -1858,7 +1858,100 @@ class ServersTest(HeatTestCase): self.m.ReplayAll() self.assertEqual(server._resolve_attribute("accessIPv4"), '') + self.m.VerifyAll() + def test_default_instance_user(self): + """The default value for instance_user in heat.conf is ec2-user.""" + return_server = self.fc.servers.list()[1] + server = self._setup_test_server(return_server, 'default_user') + self.m.StubOutWithMock(nova_utils, 'build_userdata') + nova_utils.build_userdata(server, + 'wordpress', + instance_user='ec2-user', + user_data_format='HEAT_CFNTOOLS') + self.m.ReplayAll() + scheduler.TaskRunner(server.create)() + self.m.VerifyAll() + + def test_admin_user_property(self): + """Test the admin_user property on the server overrides instance_user. + + Launching the instance should call build_userdata with the + custom user name. This property is deprecated and will be + removed in Juno. + """ + return_server = self.fc.servers.list()[1] + stack_name = 'stack_with_custom_admin_user_server' + (t, stack) = self._setup_test_stack(stack_name) + + t['Resources']['WebServer']['Properties']['admin_user'] = 'custom_user' + server = servers.Server('create_metadata_test_server', + t['Resources']['WebServer'], stack) + server.t = server.stack.resolve_runtime_data(server.t) + self.m.StubOutWithMock(self.fc.servers, 'create') + self.fc.servers.create( + image=mox.IgnoreArg(), flavor=mox.IgnoreArg(), key_name='test', + name=mox.IgnoreArg(), security_groups=[], + userdata=mox.IgnoreArg(), scheduler_hints=None, + meta=mox.IgnoreArg(), nics=None, availability_zone=None, + block_device_mapping=None, config_drive=None, + disk_config=None, reservation_id=None, files={}, + admin_pass=None).AndReturn(return_server) + self.m.StubOutWithMock(server, 'nova') + server.nova().MultipleTimes().AndReturn(self.fc) + self.m.StubOutWithMock(clients.OpenStackClients, 'nova') + clients.OpenStackClients.nova().MultipleTimes().AndReturn(self.fc) + self.m.StubOutWithMock(nova_utils, 'build_userdata') + nova_utils.build_userdata(server, + 'wordpress', + instance_user='custom_user', + user_data_format='HEAT_CFNTOOLS') + self.m.ReplayAll() + scheduler.TaskRunner(server.create)() + self.m.VerifyAll() + + def test_custom_instance_user(self): + """Test instance_user in heat.conf being set to a custom value. + + Launching the instance should call build_userdata with the + custom user name. + + This option is deprecated and will be removed in Juno. + """ + return_server = self.fc.servers.list()[1] + server = self._setup_test_server(return_server, 'custom_user') + self.m.StubOutWithMock(servers.cfg.CONF, 'instance_user') + servers.cfg.CONF.instance_user = 'custom_user' + self.m.StubOutWithMock(nova_utils, 'build_userdata') + nova_utils.build_userdata(server, + 'wordpress', + instance_user='custom_user', + user_data_format='HEAT_CFNTOOLS') + self.m.ReplayAll() + scheduler.TaskRunner(server.create)() + self.m.VerifyAll() + + def test_empty_instance_user(self): + """Test instance_user in heat.conf being empty. + + Launching the instance should not pass any user to + build_userdata. The default cloud-init user set up for the image + will be used instead. + + This will the default behaviour in Juno once we remove the + instance_user option. + """ + return_server = self.fc.servers.list()[1] + server = self._setup_test_server(return_server, 'custom_user') + self.m.StubOutWithMock(servers.cfg.CONF, 'instance_user') + servers.cfg.CONF.instance_user = '' + self.m.StubOutWithMock(nova_utils, 'build_userdata') + nova_utils.build_userdata(server, + 'wordpress', + instance_user=None, + user_data_format='HEAT_CFNTOOLS') + self.m.ReplayAll() + scheduler.TaskRunner(server.create)() self.m.VerifyAll() diff --git a/heat/tests/test_server_tags.py b/heat/tests/test_server_tags.py index e0f10f8893..00f61a1dac 100644 --- a/heat/tests/test_server_tags.py +++ b/heat/tests/test_server_tags.py @@ -151,11 +151,13 @@ class ServerTagsTest(HeatTestCase): # need to resolve the template functions server_userdata = nova_utils.build_userdata( instance, - instance.t['Properties']['UserData']) + instance.t['Properties']['UserData'], + 'ec2-user') self.m.StubOutWithMock(nova_utils, 'build_userdata') nova_utils.build_userdata( instance, - instance.t['Properties']['UserData']).AndReturn(server_userdata) + instance.t['Properties']['UserData'], + 'ec2-user').AndReturn(server_userdata) self.m.StubOutWithMock(self.fc.servers, 'create') self.fc.servers.create(