Skip to content

Commit 133ad2c

Browse files
committed
set_hostname: When present in metadata, set it before network bringup.
When instance meta-data provides hostname information, run cc_set_hostname in the init-local or init-net stage before network comes up. Prevent an initial DHCP request which leaks the stock cloud-image default hostname before the meta-data provided hostname was processed. A leaked cloud-image hostname adversely affects Dynamic DNS which would reallocate 'ubuntu' hostname in DNS to every instance brought up by cloud-init. These instances would only update DNS to the cloud-init configured hostname upon DHCP lease renewal. This branch extends the get_hostname methods in datasource, cloud and util to limit results to metadata_only to avoid extra cost of querying the distro for hostname information if metadata does not provide that information. LP: #1746455
1 parent 76460b6 commit 133ad2c

File tree

9 files changed

+449
-22
lines changed

9 files changed

+449
-22
lines changed

cloudinit/cloud.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,9 @@ def get_public_ssh_keys(self):
7878
def get_locale(self):
7979
return self.datasource.get_locale()
8080

81-
def get_hostname(self, fqdn=False):
82-
return self.datasource.get_hostname(fqdn=fqdn)
81+
def get_hostname(self, fqdn=False, metadata_only=False):
82+
return self.datasource.get_hostname(
83+
fqdn=fqdn, metadata_only=metadata_only)
8384

8485
def device_name_to_device(self, name):
8586
return self.datasource.device_name_to_device(name)

cloudinit/cmd/main.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040

4141
from cloudinit import atomic_helper
4242

43+
from cloudinit.config import cc_set_hostname
4344
from cloudinit.dhclient_hook import LogDhclient
4445

4546

@@ -352,6 +353,11 @@ def main_init(name, args):
352353
LOG.debug("[%s] %s will now be targeting instance id: %s. new=%s",
353354
mode, name, iid, init.is_new_instance())
354355

356+
if mode == sources.DSMODE_LOCAL:
357+
# Before network comes up, set any configured hostname to allow
358+
# dhcp clients to advertize this hostname to any DDNS services
359+
# LP: #1746455.
360+
_maybe_set_hostname(init, stage='local', retry_stage='network')
355361
init.apply_network_config(bring_up=bool(mode != sources.DSMODE_LOCAL))
356362

357363
if mode == sources.DSMODE_LOCAL:
@@ -368,6 +374,7 @@ def main_init(name, args):
368374
init.setup_datasource()
369375
# update fully realizes user-data (pulling in #include if necessary)
370376
init.update()
377+
_maybe_set_hostname(init, stage='init-net', retry_stage='modules:config')
371378
# Stage 7
372379
try:
373380
# Attempt to consume the data per instance.
@@ -681,6 +688,24 @@ def status_wrapper(name, args, data_d=None, link_d=None):
681688
return len(v1[mode]['errors'])
682689

683690

691+
def _maybe_set_hostname(init, stage, retry_stage):
692+
"""Call set-hostname if metadata, vendordata or userdata provides it.
693+
694+
@param stage: String representing current stage in which we are running.
695+
@param retry_stage: String represented logs upon error setting hostname.
696+
"""
697+
cloud = init.cloudify()
698+
(hostname, _fqdn) = util.get_hostname_fqdn(
699+
init.cfg, cloud, metadata_only=True)
700+
if hostname: # meta-data or user-data hostname content
701+
try:
702+
cc_set_hostname.handle('set-hostname', init.cfg, cloud, LOG, None)
703+
except cc_set_hostname.SetHostnameError as e:
704+
LOG.debug(
705+
'Failed setting hostname in %s stage. Will'
706+
' retry in %s stage. Error: %s.', stage, retry_stage, str(e))
707+
708+
684709
def main_features(name, args):
685710
sys.stdout.write('\n'.join(sorted(version.FEATURES)) + '\n')
686711

cloudinit/cmd/tests/test_main.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# This file is part of cloud-init. See LICENSE file for license information.
2+
3+
from collections import namedtuple
4+
import copy
5+
import os
6+
from six import StringIO
7+
8+
from cloudinit.cmd import main
9+
from cloudinit.util import (
10+
ensure_dir, load_file, write_file, yaml_dumps)
11+
from cloudinit.tests.helpers import (
12+
FilesystemMockingTestCase, wrap_and_call)
13+
14+
mypaths = namedtuple('MyPaths', 'run_dir')
15+
myargs = namedtuple('MyArgs', 'debug files force local reporter subcommand')
16+
17+
18+
class TestMain(FilesystemMockingTestCase):
19+
20+
with_logs = True
21+
22+
def setUp(self):
23+
super(TestMain, self).setUp()
24+
self.new_root = self.tmp_dir()
25+
self.cloud_dir = self.tmp_path('var/lib/cloud/', dir=self.new_root)
26+
os.makedirs(self.cloud_dir)
27+
self.replicateTestRoot('simple_ubuntu', self.new_root)
28+
self.cfg = {
29+
'datasource_list': ['None'],
30+
'runcmd': ['ls /etc'], # test ALL_DISTROS
31+
'system_info': {'paths': {'cloud_dir': self.cloud_dir,
32+
'run_dir': self.new_root}},
33+
'write_files': [
34+
{
35+
'path': '/etc/blah.ini',
36+
'content': 'blah',
37+
'permissions': 0o755,
38+
},
39+
],
40+
'cloud_init_modules': ['write-files', 'runcmd'],
41+
}
42+
cloud_cfg = yaml_dumps(self.cfg)
43+
ensure_dir(os.path.join(self.new_root, 'etc', 'cloud'))
44+
self.cloud_cfg_file = os.path.join(
45+
self.new_root, 'etc', 'cloud', 'cloud.cfg')
46+
write_file(self.cloud_cfg_file, cloud_cfg)
47+
self.patchOS(self.new_root)
48+
self.patchUtils(self.new_root)
49+
self.stderr = StringIO()
50+
self.patchStdoutAndStderr(stderr=self.stderr)
51+
52+
def test_main_init_run_net_stops_on_file_no_net(self):
53+
"""When no-net file is present, main_init does not process modules."""
54+
stop_file = os.path.join(self.cloud_dir, 'data', 'no-net') # stop file
55+
write_file(stop_file, '')
56+
cmdargs = myargs(
57+
debug=False, files=None, force=False, local=False, reporter=None,
58+
subcommand='init')
59+
(item1, item2) = wrap_and_call(
60+
'cloudinit.cmd.main',
61+
{'util.close_stdin': True,
62+
'netinfo.debug_info': 'my net debug info',
63+
'util.fixup_output': ('outfmt', 'errfmt')},
64+
main.main_init, 'init', cmdargs)
65+
# We should not run write_files module
66+
self.assertFalse(
67+
os.path.exists(os.path.join(self.new_root, 'etc/blah.ini')),
68+
'Unexpected run of write_files module produced blah.ini')
69+
self.assertEqual([], item2)
70+
# Instancify is called
71+
instance_id_path = 'var/lib/cloud/data/instance-id'
72+
self.assertFalse(
73+
os.path.exists(os.path.join(self.new_root, instance_id_path)),
74+
'Unexpected call to datasource.instancify produced instance-id')
75+
expected_logs = [
76+
"Exiting. stop file ['{stop_file}'] existed\n".format(
77+
stop_file=stop_file),
78+
'my net debug info' # netinfo.debug_info
79+
]
80+
for log in expected_logs:
81+
self.assertIn(log, self.stderr.getvalue())
82+
83+
def test_main_init_run_net_runs_modules(self):
84+
"""Modules like write_files are run in 'net' mode."""
85+
cmdargs = myargs(
86+
debug=False, files=None, force=False, local=False, reporter=None,
87+
subcommand='init')
88+
(item1, item2) = wrap_and_call(
89+
'cloudinit.cmd.main',
90+
{'util.close_stdin': True,
91+
'netinfo.debug_info': 'my net debug info',
92+
'util.fixup_output': ('outfmt', 'errfmt')},
93+
main.main_init, 'init', cmdargs)
94+
self.assertEqual([], item2)
95+
# Instancify is called
96+
instance_id_path = 'var/lib/cloud/data/instance-id'
97+
self.assertEqual(
98+
'iid-datasource-none\n',
99+
os.path.join(load_file(
100+
os.path.join(self.new_root, instance_id_path))))
101+
# modules are run (including write_files)
102+
self.assertEqual(
103+
'blah', load_file(os.path.join(self.new_root, 'etc/blah.ini')))
104+
expected_logs = [
105+
'network config is disabled by fallback', # apply_network_config
106+
'my net debug info', # netinfo.debug_info
107+
'no previous run detected'
108+
]
109+
for log in expected_logs:
110+
self.assertIn(log, self.stderr.getvalue())
111+
112+
def test_main_init_run_net_calls_set_hostname_when_metadata_present(self):
113+
"""When local-hostname metadata is present, call cc_set_hostname."""
114+
self.cfg['datasource'] = {
115+
'None': {'metadata': {'local-hostname': 'md-hostname'}}}
116+
cloud_cfg = yaml_dumps(self.cfg)
117+
write_file(self.cloud_cfg_file, cloud_cfg)
118+
cmdargs = myargs(
119+
debug=False, files=None, force=False, local=False, reporter=None,
120+
subcommand='init')
121+
122+
def set_hostname(name, cfg, cloud, log, args):
123+
self.assertEqual('set-hostname', name)
124+
updated_cfg = copy.deepcopy(self.cfg)
125+
updated_cfg.update(
126+
{'def_log_file': '/var/log/cloud-init.log',
127+
'log_cfgs': [],
128+
'syslog_fix_perms': ['syslog:adm', 'root:adm', 'root:wheel'],
129+
'vendor_data': {'enabled': True, 'prefix': []}})
130+
updated_cfg.pop('system_info')
131+
132+
self.assertEqual(updated_cfg, cfg)
133+
self.assertEqual(main.LOG, log)
134+
self.assertIsNone(args)
135+
136+
(item1, item2) = wrap_and_call(
137+
'cloudinit.cmd.main',
138+
{'util.close_stdin': True,
139+
'netinfo.debug_info': 'my net debug info',
140+
'cc_set_hostname.handle': {'side_effect': set_hostname},
141+
'util.fixup_output': ('outfmt', 'errfmt')},
142+
main.main_init, 'init', cmdargs)
143+
self.assertEqual([], item2)
144+
# Instancify is called
145+
instance_id_path = 'var/lib/cloud/data/instance-id'
146+
self.assertEqual(
147+
'iid-datasource-none\n',
148+
os.path.join(load_file(
149+
os.path.join(self.new_root, instance_id_path))))
150+
# modules are run (including write_files)
151+
self.assertEqual(
152+
'blah', load_file(os.path.join(self.new_root, 'etc/blah.ini')))
153+
expected_logs = [
154+
'network config is disabled by fallback', # apply_network_config
155+
'my net debug info', # netinfo.debug_info
156+
'no previous run detected'
157+
]
158+
for log in expected_logs:
159+
self.assertIn(log, self.stderr.getvalue())
160+
161+
# vi: ts=4 expandtab

cloudinit/config/cc_set_hostname.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,51 @@
3232
hostname: <fqdn/hostname>
3333
"""
3434

35+
import os
36+
37+
38+
from cloudinit.atomic_helper import write_json
3539
from cloudinit import util
3640

3741

42+
class SetHostnameError(Exception):
43+
"""Raised when the distro runs into an exception when setting hostname.
44+
45+
This may happen if we attempt to set the hostname early in cloud-init's
46+
init-local timeframe as certain services may not be running yet.
47+
"""
48+
pass
49+
50+
3851
def handle(name, cfg, cloud, log, _args):
3952
if util.get_cfg_option_bool(cfg, "preserve_hostname", False):
4053
log.debug(("Configuration option 'preserve_hostname' is set,"
4154
" not setting the hostname in module %s"), name)
4255
return
43-
4456
(hostname, fqdn) = util.get_hostname_fqdn(cfg, cloud)
57+
# Check for previous successful invocation of set-hostname
58+
59+
# set-hostname artifact file accounts for both hostname and fqdn
60+
# deltas. As such, it's format is different than cc_update_hostname's
61+
# previous-hostname file which only contains the base hostname.
62+
# TODO consolidate previous-hostname and set-hostname artifact files and
63+
# distro._read_hostname implementation so we only validate one artifact.
64+
prev_fn = os.path.join(cloud.get_cpath('data'), "set-hostname")
65+
prev_hostname = {}
66+
if os.path.exists(prev_fn):
67+
prev_hostname = util.load_json(util.load_file(prev_fn))
68+
hostname_changed = (hostname != prev_hostname.get('hostname') or
69+
fqdn != prev_hostname.get('fqdn'))
70+
if not hostname_changed:
71+
log.debug('No hostname changes. Skipping set-hostname')
72+
return
73+
log.debug("Setting the hostname to %s (%s)", fqdn, hostname)
4574
try:
46-
log.debug("Setting the hostname to %s (%s)", fqdn, hostname)
4775
cloud.distro.set_hostname(hostname, fqdn)
48-
except Exception:
49-
util.logexc(log, "Failed to set the hostname to %s (%s)", fqdn,
50-
hostname)
51-
raise
76+
except Exception as e:
77+
msg = "Failed to set the hostname to %s (%s)" % (fqdn, hostname)
78+
util.logexc(log, msg)
79+
raise SetHostnameError("%s: %s" % (msg, e))
80+
write_json(prev_fn, {'hostname': hostname, 'fqdn': fqdn})
5281

5382
# vi: ts=4 expandtab

cloudinit/sources/__init__.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,21 +276,34 @@ def get_instance_id(self):
276276
return "iid-datasource"
277277
return str(self.metadata['instance-id'])
278278

279-
def get_hostname(self, fqdn=False, resolve_ip=False):
279+
def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
280+
"""Get hostname or fqdn from the datasource. Look it up if desired.
281+
282+
@param fqdn: Boolean, set True to return hostname with domain.
283+
@param resolve_ip: Boolean, set True to attempt to resolve an ipv4
284+
address provided in local-hostname meta-data.
285+
@param metadata_only: Boolean, set True to avoid looking up hostname
286+
if meta-data doesn't have local-hostname present.
287+
288+
@return: hostname or qualified hostname. Optionally return None when
289+
metadata_only is True and local-hostname data is not available.
290+
"""
280291
defdomain = "localdomain"
281292
defhost = "localhost"
282293
domain = defdomain
283294

284295
if not self.metadata or 'local-hostname' not in self.metadata:
296+
if metadata_only:
297+
return None
285298
# this is somewhat questionable really.
286299
# the cloud datasource was asked for a hostname
287300
# and didn't have one. raising error might be more appropriate
288301
# but instead, basically look up the existing hostname
289302
toks = []
290303
hostname = util.get_hostname()
291-
fqdn = util.get_fqdn_from_hosts(hostname)
292-
if fqdn and fqdn.find(".") > 0:
293-
toks = str(fqdn).split(".")
304+
hosts_fqdn = util.get_fqdn_from_hosts(hostname)
305+
if hosts_fqdn and hosts_fqdn.find(".") > 0:
306+
toks = str(hosts_fqdn).split(".")
294307
elif hostname and hostname.find(".") > 0:
295308
toks = str(hostname).split(".")
296309
elif hostname:

0 commit comments

Comments
 (0)