Merge ~chad.smith/cloud-init:ubuntu/devel into cloud-init:ubuntu/devel

Proposed by Chad Smith
Status: Merged
Merged at revision: b602a10c0620f77882113e14c145b0877a455b02
Proposed branch: ~chad.smith/cloud-init:ubuntu/devel
Merge into: cloud-init:ubuntu/devel
Diff against target: 2392 lines (+1470/-394)
27 files modified
cloudinit/apport.py (+23/-4)
cloudinit/config/cc_ntp.py (+407/-78)
cloudinit/distros/__init__.py (+12/-0)
cloudinit/distros/opensuse.py (+24/-0)
cloudinit/distros/ubuntu.py (+19/-0)
cloudinit/templater.py (+9/-1)
config/cloud.cfg.tmpl (+2/-0)
debian/changelog (+12/-0)
templates/chrony.conf.debian.tmpl (+39/-0)
templates/chrony.conf.fedora.tmpl (+48/-0)
templates/chrony.conf.opensuse.tmpl (+38/-0)
templates/chrony.conf.rhel.tmpl (+45/-0)
templates/chrony.conf.sles.tmpl (+38/-0)
templates/chrony.conf.ubuntu.tmpl (+42/-0)
tests/cloud_tests/testcases/base.py (+5/-4)
tests/cloud_tests/testcases/modules/ntp.yaml (+1/-0)
tests/cloud_tests/testcases/modules/ntp_chrony.py (+15/-0)
tests/cloud_tests/testcases/modules/ntp_chrony.yaml (+17/-0)
tests/cloud_tests/testcases/modules/ntp_pools.yaml (+1/-0)
tests/cloud_tests/testcases/modules/ntp_servers.yaml (+1/-0)
tests/cloud_tests/testcases/modules/ntp_timesyncd.py (+15/-0)
tests/cloud_tests/testcases/modules/ntp_timesyncd.yaml (+15/-0)
tests/unittests/test_distros/test_netconfig.py (+6/-0)
tests/unittests/test_distros/test_user_data_normalize.py (+6/-0)
tests/unittests/test_handler/test_handler_ntp.py (+578/-303)
tests/unittests/test_templating.py (+40/-1)
tools/make-tarball (+12/-3)
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Approve
Scott Moser Pending
Review via email: mp+343136@code.launchpad.net

Commit message

Sync bug fixes from tip into ubuntu/devel for Bionic FFe release.

To post a comment you must log in.
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:b602a10c0620f77882113e14c145b0877a455b02
https://jenkins.ubuntu.com/server/job/cloud-init-ci/1004/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/1004/rebuild

review: Approve (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/cloudinit/apport.py b/cloudinit/apport.py
2index 618b016..130ff26 100644
3--- a/cloudinit/apport.py
4+++ b/cloudinit/apport.py
5@@ -13,10 +13,29 @@ except ImportError:
6
7
8 KNOWN_CLOUD_NAMES = [
9- 'Amazon - Ec2', 'AliYun', 'AltCloud', 'Azure', 'Bigstep', 'CloudSigma',
10- 'CloudStack', 'DigitalOcean', 'GCE - Google Compute Engine',
11- 'Hetzner Cloud', 'MAAS', 'NoCloud', 'OpenNebula', 'OpenStack', 'OVF',
12- 'Scaleway', 'SmartOS', 'VMware', 'Other']
13+ 'AliYun',
14+ 'AltCloud',
15+ 'Amazon - Ec2',
16+ 'Azure',
17+ 'Bigstep',
18+ 'Brightbox',
19+ 'CloudSigma',
20+ 'CloudStack',
21+ 'DigitalOcean',
22+ 'GCE - Google Compute Engine',
23+ 'Hetzner Cloud',
24+ 'IBM - (aka SoftLayer or BlueMix)',
25+ 'LXD',
26+ 'MAAS',
27+ 'NoCloud',
28+ 'OpenNebula',
29+ 'OpenStack',
30+ 'OVF',
31+ 'OpenTelekomCloud',
32+ 'Scaleway',
33+ 'SmartOS',
34+ 'VMware',
35+ 'Other']
36
37 # Potentially clear text collected logs
38 CLOUDINIT_LOG = '/var/log/cloud-init.log'
39diff --git a/cloudinit/config/cc_ntp.py b/cloudinit/config/cc_ntp.py
40index cbd0237..9e074bd 100644
41--- a/cloudinit/config/cc_ntp.py
42+++ b/cloudinit/config/cc_ntp.py
43@@ -10,20 +10,95 @@ from cloudinit.config.schema import (
44 get_schema_doc, validate_cloudconfig_schema)
45 from cloudinit import log as logging
46 from cloudinit.settings import PER_INSTANCE
47+from cloudinit import temp_utils
48 from cloudinit import templater
49 from cloudinit import type_utils
50 from cloudinit import util
51
52+import copy
53 import os
54+import six
55 from textwrap import dedent
56
57 LOG = logging.getLogger(__name__)
58
59 frequency = PER_INSTANCE
60 NTP_CONF = '/etc/ntp.conf'
61-TIMESYNCD_CONF = '/etc/systemd/timesyncd.conf.d/cloud-init.conf'
62 NR_POOL_SERVERS = 4
63-distros = ['centos', 'debian', 'fedora', 'opensuse', 'sles', 'ubuntu']
64+distros = ['centos', 'debian', 'fedora', 'opensuse', 'rhel', 'sles', 'ubuntu']
65+
66+NTP_CLIENT_CONFIG = {
67+ 'chrony': {
68+ 'check_exe': 'chronyd',
69+ 'confpath': '/etc/chrony.conf',
70+ 'packages': ['chrony'],
71+ 'service_name': 'chrony',
72+ 'template_name': 'chrony.conf.{distro}',
73+ 'template': None,
74+ },
75+ 'ntp': {
76+ 'check_exe': 'ntpd',
77+ 'confpath': NTP_CONF,
78+ 'packages': ['ntp'],
79+ 'service_name': 'ntp',
80+ 'template_name': 'ntp.conf.{distro}',
81+ 'template': None,
82+ },
83+ 'ntpdate': {
84+ 'check_exe': 'ntpdate',
85+ 'confpath': NTP_CONF,
86+ 'packages': ['ntpdate'],
87+ 'service_name': 'ntpdate',
88+ 'template_name': 'ntp.conf.{distro}',
89+ 'template': None,
90+ },
91+ 'systemd-timesyncd': {
92+ 'check_exe': '/lib/systemd/systemd-timesyncd',
93+ 'confpath': '/etc/systemd/timesyncd.conf.d/cloud-init.conf',
94+ 'packages': [],
95+ 'service_name': 'systemd-timesyncd',
96+ 'template_name': 'timesyncd.conf',
97+ 'template': None,
98+ },
99+}
100+
101+# This is Distro-specific configuration overrides of the base config
102+DISTRO_CLIENT_CONFIG = {
103+ 'debian': {
104+ 'chrony': {
105+ 'confpath': '/etc/chrony/chrony.conf',
106+ },
107+ },
108+ 'opensuse': {
109+ 'chrony': {
110+ 'service_name': 'chronyd',
111+ },
112+ 'ntp': {
113+ 'confpath': '/etc/ntp.conf',
114+ 'service_name': 'ntpd',
115+ },
116+ 'systemd-timesyncd': {
117+ 'check_exe': '/usr/lib/systemd/systemd-timesyncd',
118+ },
119+ },
120+ 'sles': {
121+ 'chrony': {
122+ 'service_name': 'chronyd',
123+ },
124+ 'ntp': {
125+ 'confpath': '/etc/ntp.conf',
126+ 'service_name': 'ntpd',
127+ },
128+ 'systemd-timesyncd': {
129+ 'check_exe': '/usr/lib/systemd/systemd-timesyncd',
130+ },
131+ },
132+ 'ubuntu': {
133+ 'chrony': {
134+ 'confpath': '/etc/chrony/chrony.conf',
135+ },
136+ },
137+}
138
139
140 # The schema definition for each cloud-config module is a strict contract for
141@@ -48,7 +123,34 @@ schema = {
142 'distros': distros,
143 'examples': [
144 dedent("""\
145+ # Override ntp with chrony configuration on Ubuntu
146+ ntp:
147+ enabled: true
148+ ntp_client: chrony # Uses cloud-init default chrony configuration
149+ """),
150+ dedent("""\
151+ # Provide a custom ntp client configuration
152 ntp:
153+ enabled: true
154+ ntp_client: myntpclient
155+ config:
156+ confpath: /etc/myntpclient/myntpclient.conf
157+ check_exe: myntpclientd
158+ packages:
159+ - myntpclient
160+ service_name: myntpclient
161+ template: |
162+ ## template:jinja
163+ # My NTP Client config
164+ {% if pools -%}# pools{% endif %}
165+ {% for pool in pools -%}
166+ pool {{pool}} iburst
167+ {% endfor %}
168+ {%- if servers %}# servers
169+ {% endif %}
170+ {% for server in servers -%}
171+ server {{server}} iburst
172+ {% endfor %}
173 pools: [0.int.pool.ntp.org, 1.int.pool.ntp.org, ntp.myorg.org]
174 servers:
175 - ntp.server.local
176@@ -83,79 +185,159 @@ schema = {
177 List of ntp servers. If both pools and servers are
178 empty, 4 default pool servers will be provided with
179 the format ``{0-3}.{distro}.pool.ntp.org``.""")
180- }
181+ },
182+ 'ntp_client': {
183+ 'type': 'string',
184+ 'default': 'auto',
185+ 'description': dedent("""\
186+ Name of an NTP client to use to configure system NTP.
187+ When unprovided or 'auto' the default client preferred
188+ by the distribution will be used. The following
189+ built-in client names can be used to override existing
190+ configuration defaults: chrony, ntp, ntpdate,
191+ systemd-timesyncd."""),
192+ },
193+ 'enabled': {
194+ 'type': 'boolean',
195+ 'default': True,
196+ 'description': dedent("""\
197+ Attempt to enable ntp clients if set to True. If set
198+ to False, ntp client will not be configured or
199+ installed"""),
200+ },
201+ 'config': {
202+ 'description': dedent("""\
203+ Configuration settings or overrides for the
204+ ``ntp_client`` specified."""),
205+ 'type': ['object'],
206+ 'properties': {
207+ 'confpath': {
208+ 'type': 'string',
209+ 'description': dedent("""\
210+ The path to where the ``ntp_client``
211+ configuration is written."""),
212+ },
213+ 'check_exe': {
214+ 'type': 'string',
215+ 'description': dedent("""\
216+ The executable name for the ``ntp_client``.
217+ For example, ntp service ``check_exe`` is
218+ 'ntpd' because it runs the ntpd binary."""),
219+ },
220+ 'packages': {
221+ 'type': 'array',
222+ 'items': {
223+ 'type': 'string',
224+ },
225+ 'uniqueItems': True,
226+ 'description': dedent("""\
227+ List of packages needed to be installed for the
228+ selected ``ntp_client``."""),
229+ },
230+ 'service_name': {
231+ 'type': 'string',
232+ 'description': dedent("""\
233+ The systemd or sysvinit service name used to
234+ start and stop the ``ntp_client``
235+ service."""),
236+ },
237+ 'template': {
238+ 'type': 'string',
239+ 'description': dedent("""\
240+ Inline template allowing users to define their
241+ own ``ntp_client`` configuration template.
242+ The value must start with '## template:jinja'
243+ to enable use of templating support.
244+ """),
245+ },
246+ },
247+ # Don't use REQUIRED_NTP_CONFIG_KEYS to allow for override
248+ # of builtin client values.
249+ 'required': [],
250+ 'minProperties': 1, # If we have config, define something
251+ 'additionalProperties': False
252+ },
253 },
254 'required': [],
255 'additionalProperties': False
256 }
257 }
258 }
259-
260-__doc__ = get_schema_doc(schema) # Supplement python help()
261+REQUIRED_NTP_CONFIG_KEYS = frozenset([
262+ 'check_exe', 'confpath', 'packages', 'service_name'])
263
264
265-def handle(name, cfg, cloud, log, _args):
266- """Enable and configure ntp."""
267- if 'ntp' not in cfg:
268- LOG.debug(
269- "Skipping module named %s, not present or disabled by cfg", name)
270- return
271- ntp_cfg = cfg['ntp']
272- if ntp_cfg is None:
273- ntp_cfg = {} # Allow empty config which will install the package
274+__doc__ = get_schema_doc(schema) # Supplement python help()
275
276- # TODO drop this when validate_cloudconfig_schema is strict=True
277- if not isinstance(ntp_cfg, (dict)):
278- raise RuntimeError(
279- "'ntp' key existed in config, but not a dictionary type,"
280- " is a {_type} instead".format(_type=type_utils.obj_name(ntp_cfg)))
281
282- validate_cloudconfig_schema(cfg, schema)
283- if ntp_installable():
284- service_name = 'ntp'
285- confpath = NTP_CONF
286- template_name = None
287- packages = ['ntp']
288- check_exe = 'ntpd'
289- else:
290- service_name = 'systemd-timesyncd'
291- confpath = TIMESYNCD_CONF
292- template_name = 'timesyncd.conf'
293- packages = []
294- check_exe = '/lib/systemd/systemd-timesyncd'
295-
296- rename_ntp_conf()
297- # ensure when ntp is installed it has a configuration file
298- # to use instead of starting up with packaged defaults
299- write_ntp_config_template(ntp_cfg, cloud, confpath, template=template_name)
300- install_ntp(cloud.distro.install_packages, packages=packages,
301- check_exe=check_exe)
302+def distro_ntp_client_configs(distro):
303+ """Construct a distro-specific ntp client config dictionary by merging
304+ distro specific changes into base config.
305
306- try:
307- reload_ntp(service_name, systemd=cloud.distro.uses_systemd())
308- except util.ProcessExecutionError as e:
309- LOG.exception("Failed to reload/start ntp service: %s", e)
310- raise
311+ @param distro: String providing the distro class name.
312+ @returns: Dict of distro configurations for ntp clients.
313+ """
314+ dcfg = DISTRO_CLIENT_CONFIG
315+ cfg = copy.copy(NTP_CLIENT_CONFIG)
316+ if distro in dcfg:
317+ cfg = util.mergemanydict([cfg, dcfg[distro]], reverse=True)
318+ return cfg
319
320
321-def ntp_installable():
322- """Check if we can install ntp package
323+def select_ntp_client(ntp_client, distro):
324+ """Determine which ntp client is to be used, consulting the distro
325+ for its preference.
326
327- Ubuntu-Core systems do not have an ntp package available, so
328- we always return False. Other systems require package managers to install
329- the ntp package If we fail to find one of the package managers, then we
330- cannot install ntp.
331+ @param ntp_client: String name of the ntp client to use.
332+ @param distro: Distro class instance.
333+ @returns: Dict of the selected ntp client or {} if none selected.
334 """
335- if util.system_is_snappy():
336- return False
337
338- if any(map(util.which, ['apt-get', 'dnf', 'yum', 'zypper'])):
339- return True
340+ # construct distro-specific ntp_client_config dict
341+ distro_cfg = distro_ntp_client_configs(distro.name)
342+
343+ # user specified client, return its config
344+ if ntp_client and ntp_client != 'auto':
345+ LOG.debug('Selected NTP client "%s" via user-data configuration',
346+ ntp_client)
347+ return distro_cfg.get(ntp_client, {})
348+
349+ # default to auto if unset in distro
350+ distro_ntp_client = distro.get_option('ntp_client', 'auto')
351+
352+ clientcfg = {}
353+ if distro_ntp_client == "auto":
354+ for client in distro.preferred_ntp_clients:
355+ cfg = distro_cfg.get(client)
356+ if util.which(cfg.get('check_exe')):
357+ LOG.debug('Selected NTP client "%s", already installed',
358+ client)
359+ clientcfg = cfg
360+ break
361+
362+ if not clientcfg:
363+ client = distro.preferred_ntp_clients[0]
364+ LOG.debug(
365+ 'Selected distro preferred NTP client "%s", not yet installed',
366+ client)
367+ clientcfg = distro_cfg.get(client)
368+ else:
369+ LOG.debug('Selected NTP client "%s" via distro system config',
370+ distro_ntp_client)
371+ clientcfg = distro_cfg.get(distro_ntp_client, {})
372+
373+ return clientcfg
374
375- return False
376
377+def install_ntp_client(install_func, packages=None, check_exe="ntpd"):
378+ """Install ntp client package if not already installed.
379
380-def install_ntp(install_func, packages=None, check_exe="ntpd"):
381+ @param install_func: function. This parameter is invoked with the contents
382+ of the packages parameter.
383+ @param packages: list. This parameter defaults to ['ntp'].
384+ @param check_exe: string. The name of a binary that indicates the package
385+ the specified package is already installed.
386+ """
387 if util.which(check_exe):
388 return
389 if packages is None:
390@@ -164,15 +346,23 @@ def install_ntp(install_func, packages=None, check_exe="ntpd"):
391 install_func(packages)
392
393
394-def rename_ntp_conf(config=None):
395- """Rename any existing ntp.conf file"""
396- if config is None: # For testing
397- config = NTP_CONF
398- if os.path.exists(config):
399- util.rename(config, config + ".dist")
400+def rename_ntp_conf(confpath=None):
401+ """Rename any existing ntp client config file
402+
403+ @param confpath: string. Specify a path to an existing ntp client
404+ configuration file.
405+ """
406+ if os.path.exists(confpath):
407+ util.rename(confpath, confpath + ".dist")
408
409
410 def generate_server_names(distro):
411+ """Generate a list of server names to populate an ntp client configuration
412+ file.
413+
414+ @param distro: string. Specify the distro name
415+ @returns: list: A list of strings representing ntp servers for this distro.
416+ """
417 names = []
418 pool_distro = distro
419 # For legal reasons x.pool.sles.ntp.org does not exist,
420@@ -185,34 +375,60 @@ def generate_server_names(distro):
421 return names
422
423
424-def write_ntp_config_template(cfg, cloud, path, template=None):
425- servers = cfg.get('servers', [])
426- pools = cfg.get('pools', [])
427+def write_ntp_config_template(distro_name, servers=None, pools=None,
428+ path=None, template_fn=None, template=None):
429+ """Render a ntp client configuration for the specified client.
430+
431+ @param distro_name: string. The distro class name.
432+ @param servers: A list of strings specifying ntp servers. Defaults to empty
433+ list.
434+ @param pools: A list of strings specifying ntp pools. Defaults to empty
435+ list.
436+ @param path: A string to specify where to write the rendered template.
437+ @param template_fn: A string to specify the template source file.
438+ @param template: A string specifying the contents of the template. This
439+ content will be written to a temporary file before being used to render
440+ the configuration file.
441+
442+ @raises: ValueError when path is None.
443+ @raises: ValueError when template_fn is None and template is None.
444+ """
445+ if not servers:
446+ servers = []
447+ if not pools:
448+ pools = []
449
450 if len(servers) == 0 and len(pools) == 0:
451- pools = generate_server_names(cloud.distro.name)
452+ pools = generate_server_names(distro_name)
453 LOG.debug(
454 'Adding distro default ntp pool servers: %s', ','.join(pools))
455
456- params = {
457- 'servers': servers,
458- 'pools': pools,
459- }
460+ if not path:
461+ raise ValueError('Invalid value for path parameter')
462
463- if template is None:
464- template = 'ntp.conf.%s' % cloud.distro.name
465+ if not template_fn and not template:
466+ raise ValueError('Not template_fn or template provided')
467
468- template_fn = cloud.get_template_filename(template)
469- if not template_fn:
470- template_fn = cloud.get_template_filename('ntp.conf')
471- if not template_fn:
472- raise RuntimeError(
473- 'No template found, not rendering {path}'.format(path=path))
474+ params = {'servers': servers, 'pools': pools}
475+ if template:
476+ tfile = temp_utils.mkstemp(prefix='template_name-', suffix=".tmpl")
477+ template_fn = tfile[1] # filepath is second item in tuple
478+ util.write_file(template_fn, content=template)
479
480 templater.render_to_file(template_fn, path, params)
481+ # clean up temporary template
482+ if template:
483+ util.del_file(template_fn)
484
485
486 def reload_ntp(service, systemd=False):
487+ """Restart or reload an ntp system service.
488+
489+ @param service: A string specifying the name of the service to be affected.
490+ @param systemd: A boolean indicating if the distro uses systemd, defaults
491+ to False.
492+ @returns: A tuple of stdout, stderr results from executing the action.
493+ """
494 if systemd:
495 cmd = ['systemctl', 'reload-or-restart', service]
496 else:
497@@ -220,4 +436,117 @@ def reload_ntp(service, systemd=False):
498 util.subp(cmd, capture=True)
499
500
501+def supplemental_schema_validation(ntp_config):
502+ """Validate user-provided ntp:config option values.
503+
504+ This function supplements flexible jsonschema validation with specific
505+ value checks to aid in triage of invalid user-provided configuration.
506+
507+ @param ntp_config: Dictionary of configuration value under 'ntp'.
508+
509+ @raises: ValueError describing invalid values provided.
510+ """
511+ errors = []
512+ missing = REQUIRED_NTP_CONFIG_KEYS.difference(set(ntp_config.keys()))
513+ if missing:
514+ keys = ', '.join(sorted(missing))
515+ errors.append(
516+ 'Missing required ntp:config keys: {keys}'.format(keys=keys))
517+ elif not any([ntp_config.get('template'),
518+ ntp_config.get('template_name')]):
519+ errors.append(
520+ 'Either ntp:config:template or ntp:config:template_name values'
521+ ' are required')
522+ for key, value in sorted(ntp_config.items()):
523+ keypath = 'ntp:config:' + key
524+ if key == 'confpath':
525+ if not all([value, isinstance(value, six.string_types)]):
526+ errors.append(
527+ 'Expected a config file path {keypath}.'
528+ ' Found ({value})'.format(keypath=keypath, value=value))
529+ elif key == 'packages':
530+ if not isinstance(value, list):
531+ errors.append(
532+ 'Expected a list of required package names for {keypath}.'
533+ ' Found ({value})'.format(keypath=keypath, value=value))
534+ elif key in ('template', 'template_name'):
535+ if value is None: # Either template or template_name can be none
536+ continue
537+ if not isinstance(value, six.string_types):
538+ errors.append(
539+ 'Expected a string type for {keypath}.'
540+ ' Found ({value})'.format(keypath=keypath, value=value))
541+ elif not isinstance(value, six.string_types):
542+ errors.append(
543+ 'Expected a string type for {keypath}.'
544+ ' Found ({value})'.format(keypath=keypath, value=value))
545+
546+ if errors:
547+ raise ValueError(r'Invalid ntp configuration:\n{errors}'.format(
548+ errors='\n'.join(errors)))
549+
550+
551+def handle(name, cfg, cloud, log, _args):
552+ """Enable and configure ntp."""
553+ if 'ntp' not in cfg:
554+ LOG.debug(
555+ "Skipping module named %s, not present or disabled by cfg", name)
556+ return
557+ ntp_cfg = cfg['ntp']
558+ if ntp_cfg is None:
559+ ntp_cfg = {} # Allow empty config which will install the package
560+
561+ # TODO drop this when validate_cloudconfig_schema is strict=True
562+ if not isinstance(ntp_cfg, (dict)):
563+ raise RuntimeError(
564+ "'ntp' key existed in config, but not a dictionary type,"
565+ " is a {_type} instead".format(_type=type_utils.obj_name(ntp_cfg)))
566+
567+ validate_cloudconfig_schema(cfg, schema)
568+
569+ # Allow users to explicitly enable/disable
570+ enabled = ntp_cfg.get('enabled', True)
571+ if util.is_false(enabled):
572+ LOG.debug("Skipping module named %s, disabled by cfg", name)
573+ return
574+
575+ # Select which client is going to be used and get the configuration
576+ ntp_client_config = select_ntp_client(ntp_cfg.get('ntp_client'),
577+ cloud.distro)
578+
579+ # Allow user ntp config to override distro configurations
580+ ntp_client_config = util.mergemanydict(
581+ [ntp_client_config, ntp_cfg.get('config', {})], reverse=True)
582+
583+ supplemental_schema_validation(ntp_client_config)
584+ rename_ntp_conf(confpath=ntp_client_config.get('confpath'))
585+
586+ template_fn = None
587+ if not ntp_client_config.get('template'):
588+ template_name = (
589+ ntp_client_config.get('template_name').replace('{distro}',
590+ cloud.distro.name))
591+ template_fn = cloud.get_template_filename(template_name)
592+ if not template_fn:
593+ msg = ('No template found, not rendering %s' %
594+ ntp_client_config.get('template_name'))
595+ raise RuntimeError(msg)
596+
597+ write_ntp_config_template(cloud.distro.name,
598+ servers=ntp_cfg.get('servers', []),
599+ pools=ntp_cfg.get('pools', []),
600+ path=ntp_client_config.get('confpath'),
601+ template_fn=template_fn,
602+ template=ntp_client_config.get('template'))
603+
604+ install_ntp_client(cloud.distro.install_packages,
605+ packages=ntp_client_config['packages'],
606+ check_exe=ntp_client_config['check_exe'])
607+ try:
608+ reload_ntp(ntp_client_config['service_name'],
609+ systemd=cloud.distro.uses_systemd())
610+ except util.ProcessExecutionError as e:
611+ LOG.exception("Failed to reload/start ntp service: %s", e)
612+ raise
613+
614 # vi: ts=4 expandtab
615diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py
616index 55260ea..6c22b07 100755
617--- a/cloudinit/distros/__init__.py
618+++ b/cloudinit/distros/__init__.py
619@@ -49,6 +49,9 @@ LOG = logging.getLogger(__name__)
620 # It could break when Amazon adds new regions and new AZs.
621 _EC2_AZ_RE = re.compile('^[a-z][a-z]-(?:[a-z]+-)+[0-9][a-z]$')
622
623+# Default NTP Client Configurations
624+PREFERRED_NTP_CLIENTS = ['chrony', 'systemd-timesyncd', 'ntp', 'ntpdate']
625+
626
627 @six.add_metaclass(abc.ABCMeta)
628 class Distro(object):
629@@ -60,6 +63,7 @@ class Distro(object):
630 tz_zone_dir = "/usr/share/zoneinfo"
631 init_cmd = ['service'] # systemctl, service etc
632 renderer_configs = {}
633+ _preferred_ntp_clients = None
634
635 def __init__(self, name, cfg, paths):
636 self._paths = paths
637@@ -339,6 +343,14 @@ class Distro(object):
638 contents.write("%s\n" % (eh))
639 util.write_file(self.hosts_fn, contents.getvalue(), mode=0o644)
640
641+ @property
642+ def preferred_ntp_clients(self):
643+ """Allow distro to determine the preferred ntp client list"""
644+ if not self._preferred_ntp_clients:
645+ self._preferred_ntp_clients = list(PREFERRED_NTP_CLIENTS)
646+
647+ return self._preferred_ntp_clients
648+
649 def _bring_up_interface(self, device_name):
650 cmd = ['ifup', device_name]
651 LOG.debug("Attempting to run bring up interface %s using command %s",
652diff --git a/cloudinit/distros/opensuse.py b/cloudinit/distros/opensuse.py
653index 162dfa0..9f90e95 100644
654--- a/cloudinit/distros/opensuse.py
655+++ b/cloudinit/distros/opensuse.py
656@@ -208,4 +208,28 @@ class Distro(distros.Distro):
657 nameservers, searchservers)
658 return dev_names
659
660+ @property
661+ def preferred_ntp_clients(self):
662+ """The preferred ntp client is dependent on the version."""
663+
664+ """Allow distro to determine the preferred ntp client list"""
665+ if not self._preferred_ntp_clients:
666+ distro_info = util.system_info()['dist']
667+ name = distro_info[0]
668+ major_ver = int(distro_info[1].split('.')[0])
669+
670+ # This is horribly complicated because of a case of
671+ # "we do not care if versions should be increasing syndrome"
672+ if (
673+ (major_ver >= 15 and 'openSUSE' not in name) or
674+ (major_ver >= 15 and 'openSUSE' in name and major_ver != 42)
675+ ):
676+ self._preferred_ntp_clients = ['chrony',
677+ 'systemd-timesyncd', 'ntp']
678+ else:
679+ self._preferred_ntp_clients = ['ntp',
680+ 'systemd-timesyncd', 'chrony']
681+
682+ return self._preferred_ntp_clients
683+
684 # vi: ts=4 expandtab
685diff --git a/cloudinit/distros/ubuntu.py b/cloudinit/distros/ubuntu.py
686index 82ca34f..fdc1f62 100644
687--- a/cloudinit/distros/ubuntu.py
688+++ b/cloudinit/distros/ubuntu.py
689@@ -10,12 +10,31 @@
690 # This file is part of cloud-init. See LICENSE file for license information.
691
692 from cloudinit.distros import debian
693+from cloudinit.distros import PREFERRED_NTP_CLIENTS
694 from cloudinit import log as logging
695+from cloudinit import util
696+
697+import copy
698
699 LOG = logging.getLogger(__name__)
700
701
702 class Distro(debian.Distro):
703+
704+ @property
705+ def preferred_ntp_clients(self):
706+ """The preferred ntp client is dependent on the version."""
707+ if not self._preferred_ntp_clients:
708+ (name, version, codename) = util.system_info()['dist']
709+ # Xenial cloud-init only installed ntp, UbuntuCore has timesyncd.
710+ if codename == "xenial" and not util.system_is_snappy():
711+ self._preferred_ntp_clients = ['ntp']
712+ else:
713+ self._preferred_ntp_clients = (
714+ copy.deepcopy(PREFERRED_NTP_CLIENTS))
715+ return self._preferred_ntp_clients
716+
717 pass
718
719+
720 # vi: ts=4 expandtab
721diff --git a/cloudinit/templater.py b/cloudinit/templater.py
722index b3ea64e..9a087e1 100644
723--- a/cloudinit/templater.py
724+++ b/cloudinit/templater.py
725@@ -121,7 +121,11 @@ def detect_template(text):
726 def render_from_file(fn, params):
727 if not params:
728 params = {}
729- template_type, renderer, content = detect_template(util.load_file(fn))
730+ # jinja in python2 uses unicode internally. All py2 str will be decoded.
731+ # If it is given a str that has non-ascii then it will raise a
732+ # UnicodeDecodeError. So we explicitly convert to unicode type here.
733+ template_type, renderer, content = detect_template(
734+ util.load_file(fn, decode=False).decode('utf-8'))
735 LOG.debug("Rendering content of '%s' using renderer %s", fn, template_type)
736 return renderer(content, params)
737
738@@ -132,11 +136,15 @@ def render_to_file(fn, outfn, params, mode=0o644):
739
740
741 def render_string_to_file(content, outfn, params, mode=0o644):
742+ """Render string (or py2 unicode) to file.
743+ Warning: py2 str with non-ascii chars will cause UnicodeDecodeError."""
744 contents = render_string(content, params)
745 util.write_file(outfn, contents, mode=mode)
746
747
748 def render_string(content, params):
749+ """Render string (or py2 unicode).
750+ Warning: py2 str with non-ascii chars will cause UnicodeDecodeError."""
751 if not params:
752 params = {}
753 template_type, renderer, content = detect_template(content)
754diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl
755index 3129d4e..5619de3 100644
756--- a/config/cloud.cfg.tmpl
757+++ b/config/cloud.cfg.tmpl
758@@ -151,6 +151,8 @@ system_info:
759 groups: [adm, audio, cdrom, dialout, dip, floppy, lxd, netdev, plugdev, sudo, video]
760 sudo: ["ALL=(ALL) NOPASSWD:ALL"]
761 shell: /bin/bash
762+ # Automatically discover the best ntp_client
763+ ntp_client: auto
764 # Other config here will be given to the distro class and/or path classes
765 paths:
766 cloud_dir: /var/lib/cloud/
767diff --git a/debian/changelog b/debian/changelog
768index 390f345..d3a4234 100644
769--- a/debian/changelog
770+++ b/debian/changelog
771@@ -1,3 +1,15 @@
772+cloud-init (18.2-9-g49b562c9-0ubuntu1) bionic; urgency=medium
773+
774+ * New upstream snapshot.
775+ - tools: Fix make-tarball cli tool usage for development
776+ - renderer: support unicode in render_from_file.
777+ - Implement ntp client spec with auto support for distro selection
778+ (LP: #1749722)
779+ - Apport: add Brightbox, IBM, LXD, and OpenTelekomCloud to list of clouds.
780+ - tests: fix ec2 integration network metadata validation
781+
782+ -- Chad Smith <chad.smith@canonical.com> Thu, 12 Apr 2018 16:06:24 -0600
783+
784 cloud-init (18.2-4-g05926e48-0ubuntu2) bionic; urgency=medium
785
786 * debian/cloud-init.templates: enable IBMCloud by default (LP: #1762773).
787diff --git a/templates/chrony.conf.debian.tmpl b/templates/chrony.conf.debian.tmpl
788new file mode 100644
789index 0000000..661bf04
790--- /dev/null
791+++ b/templates/chrony.conf.debian.tmpl
792@@ -0,0 +1,39 @@
793+## template:jinja
794+# Welcome to the chrony configuration file. See chrony.conf(5) for more
795+# information about usuable directives.
796+{% if pools %}# pools
797+{% endif %}
798+{% for pool in pools -%}
799+pool {{pool}} iburst
800+{% endfor %}
801+{%- if servers %}# servers
802+{% endif %}
803+{% for server in servers -%}
804+server {{server}} iburst
805+{% endfor %}
806+
807+# This directive specify the location of the file containing ID/key pairs for
808+# NTP authentication.
809+keyfile /etc/chrony/chrony.keys
810+
811+# This directive specify the file into which chronyd will store the rate
812+# information.
813+driftfile /var/lib/chrony/chrony.drift
814+
815+# Uncomment the following line to turn logging on.
816+#log tracking measurements statistics
817+
818+# Log files location.
819+logdir /var/log/chrony
820+
821+# Stop bad estimates upsetting machine clock.
822+maxupdateskew 100.0
823+
824+# This directive enables kernel synchronisation (every 11 minutes) of the
825+# real-time clock. Note that it can’t be used along with the 'rtcfile' directive.
826+rtcsync
827+
828+# Step the system clock instead of slewing it if the adjustment is larger than
829+# one second, but only in the first three clock updates.
830+makestep 1 3
831+
832diff --git a/templates/chrony.conf.fedora.tmpl b/templates/chrony.conf.fedora.tmpl
833new file mode 100644
834index 0000000..8551f79
835--- /dev/null
836+++ b/templates/chrony.conf.fedora.tmpl
837@@ -0,0 +1,48 @@
838+## template:jinja
839+# Use public servers from the pool.ntp.org project.
840+# Please consider joining the pool (http://www.pool.ntp.org/join.html).
841+{% if pools %}# pools
842+{% endif %}
843+{% for pool in pools -%}
844+pool {{pool}} iburst
845+{% endfor %}
846+{%- if servers %}# servers
847+{% endif %}
848+{% for server in servers -%}
849+server {{server}} iburst
850+{% endfor %}
851+
852+# Record the rate at which the system clock gains/losses time.
853+driftfile /var/lib/chrony/drift
854+
855+# Allow the system clock to be stepped in the first three updates
856+# if its offset is larger than 1 second.
857+makestep 1.0 3
858+
859+# Enable kernel synchronization of the real-time clock (RTC).
860+rtcsync
861+
862+# Enable hardware timestamping on all interfaces that support it.
863+#hwtimestamp *
864+
865+# Increase the minimum number of selectable sources required to adjust
866+# the system clock.
867+#minsources 2
868+
869+# Allow NTP client access from local network.
870+#allow 192.168.0.0/16
871+
872+# Serve time even if not synchronized to a time source.
873+#local stratum 10
874+
875+# Specify file containing keys for NTP authentication.
876+#keyfile /etc/chrony.keys
877+
878+# Get TAI-UTC offset and leap seconds from the system tz database.
879+leapsectz right/UTC
880+
881+# Specify directory for log files.
882+logdir /var/log/chrony
883+
884+# Select which information is logged.
885+#log measurements statistics tracking
886diff --git a/templates/chrony.conf.opensuse.tmpl b/templates/chrony.conf.opensuse.tmpl
887new file mode 100644
888index 0000000..a3d3e0e
889--- /dev/null
890+++ b/templates/chrony.conf.opensuse.tmpl
891@@ -0,0 +1,38 @@
892+## template:jinja
893+# Use public servers from the pool.ntp.org project.
894+# Please consider joining the pool (http://www.pool.ntp.org/join.html).
895+{% if pools %}# pools
896+{% endif %}
897+{% for pool in pools -%}
898+pool {{pool}} iburst
899+{% endfor %}
900+{%- if servers %}# servers
901+{% endif %}
902+{% for server in servers -%}
903+server {{server}} iburst
904+{% endfor %}
905+
906+# Record the rate at which the system clock gains/losses time.
907+driftfile /var/lib/chrony/drift
908+
909+# In first three updates step the system clock instead of slew
910+# if the adjustment is larger than 1 second.
911+makestep 1.0 3
912+
913+# Enable kernel synchronization of the real-time clock (RTC).
914+rtcsync
915+
916+# Allow NTP client access from local network.
917+#allow 192.168/16
918+
919+# Serve time even if not synchronized to any NTP server.
920+#local stratum 10
921+
922+# Specify file containing keys for NTP authentication.
923+#keyfile /etc/chrony.keys
924+
925+# Specify directory for log files.
926+logdir /var/log/chrony
927+
928+# Select which information is logged.
929+#log measurements statistics tracking
930diff --git a/templates/chrony.conf.rhel.tmpl b/templates/chrony.conf.rhel.tmpl
931new file mode 100644
932index 0000000..5b3542e
933--- /dev/null
934+++ b/templates/chrony.conf.rhel.tmpl
935@@ -0,0 +1,45 @@
936+## template:jinja
937+# Use public servers from the pool.ntp.org project.
938+# Please consider joining the pool (http://www.pool.ntp.org/join.html).
939+{% if pools %}# pools
940+{% endif %}
941+{% for pool in pools -%}
942+pool {{pool}} iburst
943+{% endfor %}
944+{%- if servers %}# servers
945+{% endif %}
946+{% for server in servers -%}
947+server {{server}} iburst
948+{% endfor %}
949+
950+# Record the rate at which the system clock gains/losses time.
951+driftfile /var/lib/chrony/drift
952+
953+# Allow the system clock to be stepped in the first three updates
954+# if its offset is larger than 1 second.
955+makestep 1.0 3
956+
957+# Enable kernel synchronization of the real-time clock (RTC).
958+rtcsync
959+
960+# Enable hardware timestamping on all interfaces that support it.
961+#hwtimestamp *
962+
963+# Increase the minimum number of selectable sources required to adjust
964+# the system clock.
965+#minsources 2
966+
967+# Allow NTP client access from local network.
968+#allow 192.168.0.0/16
969+
970+# Serve time even if not synchronized to a time source.
971+#local stratum 10
972+
973+# Specify file containing keys for NTP authentication.
974+#keyfile /etc/chrony.keys
975+
976+# Specify directory for log files.
977+logdir /var/log/chrony
978+
979+# Select which information is logged.
980+#log measurements statistics tracking
981diff --git a/templates/chrony.conf.sles.tmpl b/templates/chrony.conf.sles.tmpl
982new file mode 100644
983index 0000000..a3d3e0e
984--- /dev/null
985+++ b/templates/chrony.conf.sles.tmpl
986@@ -0,0 +1,38 @@
987+## template:jinja
988+# Use public servers from the pool.ntp.org project.
989+# Please consider joining the pool (http://www.pool.ntp.org/join.html).
990+{% if pools %}# pools
991+{% endif %}
992+{% for pool in pools -%}
993+pool {{pool}} iburst
994+{% endfor %}
995+{%- if servers %}# servers
996+{% endif %}
997+{% for server in servers -%}
998+server {{server}} iburst
999+{% endfor %}
1000+
1001+# Record the rate at which the system clock gains/losses time.
1002+driftfile /var/lib/chrony/drift
1003+
1004+# In first three updates step the system clock instead of slew
1005+# if the adjustment is larger than 1 second.
1006+makestep 1.0 3
1007+
1008+# Enable kernel synchronization of the real-time clock (RTC).
1009+rtcsync
1010+
1011+# Allow NTP client access from local network.
1012+#allow 192.168/16
1013+
1014+# Serve time even if not synchronized to any NTP server.
1015+#local stratum 10
1016+
1017+# Specify file containing keys for NTP authentication.
1018+#keyfile /etc/chrony.keys
1019+
1020+# Specify directory for log files.
1021+logdir /var/log/chrony
1022+
1023+# Select which information is logged.
1024+#log measurements statistics tracking
1025diff --git a/templates/chrony.conf.ubuntu.tmpl b/templates/chrony.conf.ubuntu.tmpl
1026new file mode 100644
1027index 0000000..50a6f51
1028--- /dev/null
1029+++ b/templates/chrony.conf.ubuntu.tmpl
1030@@ -0,0 +1,42 @@
1031+## template:jinja
1032+# Welcome to the chrony configuration file. See chrony.conf(5) for more
1033+# information about usuable directives.
1034+
1035+# Use servers from the NTP Pool Project. Approved by Ubuntu Technical Board
1036+# on 2011-02-08 (LP: #104525). See http://www.pool.ntp.org/join.html for
1037+# more information.
1038+{% if pools %}# pools
1039+{% endif %}
1040+{% for pool in pools -%}
1041+pool {{pool}} iburst
1042+{% endfor %}
1043+{%- if servers %}# servers
1044+{% endif %}
1045+{% for server in servers -%}
1046+server {{server}} iburst
1047+{% endfor %}
1048+
1049+# This directive specify the location of the file containing ID/key pairs for
1050+# NTP authentication.
1051+keyfile /etc/chrony/chrony.keys
1052+
1053+# This directive specify the file into which chronyd will store the rate
1054+# information.
1055+driftfile /var/lib/chrony/chrony.drift
1056+
1057+# Uncomment the following line to turn logging on.
1058+#log tracking measurements statistics
1059+
1060+# Log files location.
1061+logdir /var/log/chrony
1062+
1063+# Stop bad estimates upsetting machine clock.
1064+maxupdateskew 100.0
1065+
1066+# This directive enables kernel synchronisation (every 11 minutes) of the
1067+# real-time clock. Note that it can’t be used along with the 'rtcfile' directive.
1068+rtcsync
1069+
1070+# Step the system clock instead of slewing it if the adjustment is larger than
1071+# one second, but only in the first three clock updates.
1072+makestep 1 3
1073diff --git a/tests/cloud_tests/testcases/base.py b/tests/cloud_tests/testcases/base.py
1074index 324c7c9..7598d46 100644
1075--- a/tests/cloud_tests/testcases/base.py
1076+++ b/tests/cloud_tests/testcases/base.py
1077@@ -149,7 +149,10 @@ class CloudTestCase(unittest.TestCase):
1078 self.assertEqual(
1079 ['ds/user-data'], instance_data['base64-encoded-keys'])
1080 ds = instance_data.get('ds', {})
1081- macs = ds.get('network', {}).get('interfaces', {}).get('macs', {})
1082+ v1_data = instance_data.get('v1', {})
1083+ metadata = ds.get('meta-data', {})
1084+ macs = metadata.get(
1085+ 'network', {}).get('interfaces', {}).get('macs', {})
1086 if not macs:
1087 raise AssertionError('No network data from EC2 meta-data')
1088 # Check meta-data items we depend on
1089@@ -160,10 +163,8 @@ class CloudTestCase(unittest.TestCase):
1090 for key in expected_net_keys:
1091 self.assertIn(key, mac_data)
1092 self.assertIsNotNone(
1093- ds.get('placement', {}).get('availability-zone'),
1094+ metadata.get('placement', {}).get('availability-zone'),
1095 'Could not determine EC2 Availability zone placement')
1096- ds = instance_data.get('ds', {})
1097- v1_data = instance_data.get('v1', {})
1098 self.assertIsNotNone(
1099 v1_data['availability-zone'], 'expected ec2 availability-zone')
1100 self.assertEqual('aws', v1_data['cloud-name'])
1101diff --git a/tests/cloud_tests/testcases/modules/ntp.yaml b/tests/cloud_tests/testcases/modules/ntp.yaml
1102index 2530d72..7ea0707 100644
1103--- a/tests/cloud_tests/testcases/modules/ntp.yaml
1104+++ b/tests/cloud_tests/testcases/modules/ntp.yaml
1105@@ -4,6 +4,7 @@
1106 cloud_config: |
1107 #cloud-config
1108 ntp:
1109+ ntp_client: ntp
1110 pools: []
1111 servers: []
1112 collect_scripts:
1113diff --git a/tests/cloud_tests/testcases/modules/ntp_chrony.py b/tests/cloud_tests/testcases/modules/ntp_chrony.py
1114new file mode 100644
1115index 0000000..461630a
1116--- /dev/null
1117+++ b/tests/cloud_tests/testcases/modules/ntp_chrony.py
1118@@ -0,0 +1,15 @@
1119+# This file is part of cloud-init. See LICENSE file for license information.
1120+
1121+"""cloud-init Integration Test Verify Script."""
1122+from tests.cloud_tests.testcases import base
1123+
1124+
1125+class TestNtpChrony(base.CloudTestCase):
1126+ """Test ntp module with chrony client"""
1127+
1128+ def test_chrony_entires(self):
1129+ """Test chrony config entries"""
1130+ out = self.get_data_file('chrony_conf')
1131+ self.assertIn('.pool.ntp.org', out)
1132+
1133+# vi: ts=4 expandtab
1134diff --git a/tests/cloud_tests/testcases/modules/ntp_chrony.yaml b/tests/cloud_tests/testcases/modules/ntp_chrony.yaml
1135new file mode 100644
1136index 0000000..120735e
1137--- /dev/null
1138+++ b/tests/cloud_tests/testcases/modules/ntp_chrony.yaml
1139@@ -0,0 +1,17 @@
1140+#
1141+# ntp enabled, chrony selected, check conf file
1142+# as chrony won't start in a container
1143+#
1144+cloud_config: |
1145+ #cloud-config
1146+ ntp:
1147+ enabled: true
1148+ ntp_client: chrony
1149+collect_scripts:
1150+ chrony_conf: |
1151+ #!/bin/sh
1152+ set -- /etc/chrony.conf /etc/chrony/chrony.conf
1153+ for p in "$@"; do
1154+ [ -e "$p" ] && { cat "$p"; exit; }
1155+ done
1156+# vi: ts=4 expandtab
1157diff --git a/tests/cloud_tests/testcases/modules/ntp_pools.yaml b/tests/cloud_tests/testcases/modules/ntp_pools.yaml
1158index d490b22..60fa0fd 100644
1159--- a/tests/cloud_tests/testcases/modules/ntp_pools.yaml
1160+++ b/tests/cloud_tests/testcases/modules/ntp_pools.yaml
1161@@ -9,6 +9,7 @@ required_features:
1162 cloud_config: |
1163 #cloud-config
1164 ntp:
1165+ ntp_client: ntp
1166 pools:
1167 - 0.cloud-init.mypool
1168 - 1.cloud-init.mypool
1169diff --git a/tests/cloud_tests/testcases/modules/ntp_servers.yaml b/tests/cloud_tests/testcases/modules/ntp_servers.yaml
1170index 6b13b70..ee63667 100644
1171--- a/tests/cloud_tests/testcases/modules/ntp_servers.yaml
1172+++ b/tests/cloud_tests/testcases/modules/ntp_servers.yaml
1173@@ -6,6 +6,7 @@ required_features:
1174 cloud_config: |
1175 #cloud-config
1176 ntp:
1177+ ntp_client: ntp
1178 servers:
1179 - 172.16.15.14
1180 - 172.16.17.18
1181diff --git a/tests/cloud_tests/testcases/modules/ntp_timesyncd.py b/tests/cloud_tests/testcases/modules/ntp_timesyncd.py
1182new file mode 100644
1183index 0000000..eca750b
1184--- /dev/null
1185+++ b/tests/cloud_tests/testcases/modules/ntp_timesyncd.py
1186@@ -0,0 +1,15 @@
1187+# This file is part of cloud-init. See LICENSE file for license information.
1188+
1189+"""cloud-init Integration Test Verify Script."""
1190+from tests.cloud_tests.testcases import base
1191+
1192+
1193+class TestNtpTimesyncd(base.CloudTestCase):
1194+ """Test ntp module with systemd-timesyncd client"""
1195+
1196+ def test_timesyncd_entries(self):
1197+ """Test timesyncd config entries"""
1198+ out = self.get_data_file('timesyncd_conf')
1199+ self.assertIn('.pool.ntp.org', out)
1200+
1201+# vi: ts=4 expandtab
1202diff --git a/tests/cloud_tests/testcases/modules/ntp_timesyncd.yaml b/tests/cloud_tests/testcases/modules/ntp_timesyncd.yaml
1203new file mode 100644
1204index 0000000..ee47a74
1205--- /dev/null
1206+++ b/tests/cloud_tests/testcases/modules/ntp_timesyncd.yaml
1207@@ -0,0 +1,15 @@
1208+#
1209+# ntp enabled, systemd-timesyncd selected, check conf file
1210+# as systemd-timesyncd won't start in a container
1211+#
1212+cloud_config: |
1213+ #cloud-config
1214+ ntp:
1215+ enabled: true
1216+ ntp_client: systemd-timesyncd
1217+collect_scripts:
1218+ timesyncd_conf: |
1219+ #!/bin/sh
1220+ cat /etc/systemd/timesyncd.conf.d/cloud-init.conf
1221+
1222+# vi: ts=4 expandtab
1223diff --git a/tests/unittests/test_distros/test_netconfig.py b/tests/unittests/test_distros/test_netconfig.py
1224index 1c2e45f..7765e40 100644
1225--- a/tests/unittests/test_distros/test_netconfig.py
1226+++ b/tests/unittests/test_distros/test_netconfig.py
1227@@ -189,6 +189,12 @@ hn0: flags=8843<UP,BROADCAST,RUNNING,SIMPLEX,MULTICAST> metric 0 mtu 1500
1228 status: active
1229 """
1230
1231+ def setUp(self):
1232+ super(TestNetCfgDistro, self).setUp()
1233+ self.add_patch('cloudinit.util.system_is_snappy', 'm_snappy')
1234+ self.add_patch('cloudinit.util.system_info', 'm_sysinfo')
1235+ self.m_sysinfo.return_value = {'dist': ('Distro', '99.1', 'Codename')}
1236+
1237 def _get_distro(self, dname, renderers=None):
1238 cls = distros.fetch(dname)
1239 cfg = settings.CFG_BUILTIN
1240diff --git a/tests/unittests/test_distros/test_user_data_normalize.py b/tests/unittests/test_distros/test_user_data_normalize.py
1241index 0fa9cdb..fa4b6cf 100644
1242--- a/tests/unittests/test_distros/test_user_data_normalize.py
1243+++ b/tests/unittests/test_distros/test_user_data_normalize.py
1244@@ -22,6 +22,12 @@ bcfg = {
1245
1246 class TestUGNormalize(TestCase):
1247
1248+ def setUp(self):
1249+ super(TestUGNormalize, self).setUp()
1250+ self.add_patch('cloudinit.util.system_is_snappy', 'm_snappy')
1251+ self.add_patch('cloudinit.util.system_info', 'm_sysinfo')
1252+ self.m_sysinfo.return_value = {'dist': ('Distro', '99.1', 'Codename')}
1253+
1254 def _make_distro(self, dtype, def_user=None):
1255 cfg = dict(settings.CFG_BUILTIN)
1256 cfg['system_info']['distro'] = dtype
1257diff --git a/tests/unittests/test_handler/test_handler_ntp.py b/tests/unittests/test_handler/test_handler_ntp.py
1258index 695897c..02676aa 100644
1259--- a/tests/unittests/test_handler/test_handler_ntp.py
1260+++ b/tests/unittests/test_handler/test_handler_ntp.py
1261@@ -4,20 +4,21 @@ from cloudinit.config import cc_ntp
1262 from cloudinit.sources import DataSourceNone
1263 from cloudinit import (distros, helpers, cloud, util)
1264 from cloudinit.tests.helpers import (
1265- FilesystemMockingTestCase, mock, skipUnlessJsonSchema)
1266+ CiTestCase, FilesystemMockingTestCase, mock, skipUnlessJsonSchema)
1267
1268
1269+import copy
1270 import os
1271 from os.path import dirname
1272 import shutil
1273
1274-NTP_TEMPLATE = b"""\
1275+NTP_TEMPLATE = """\
1276 ## template: jinja
1277 servers {{servers}}
1278 pools {{pools}}
1279 """
1280
1281-TIMESYNCD_TEMPLATE = b"""\
1282+TIMESYNCD_TEMPLATE = """\
1283 ## template:jinja
1284 [Time]
1285 {% if servers or pools -%}
1286@@ -32,56 +33,88 @@ class TestNtp(FilesystemMockingTestCase):
1287
1288 def setUp(self):
1289 super(TestNtp, self).setUp()
1290- self.subp = util.subp
1291 self.new_root = self.tmp_dir()
1292+ self.add_patch('cloudinit.util.system_is_snappy', 'm_snappy')
1293+ self.m_snappy.return_value = False
1294+ self.add_patch('cloudinit.util.system_info', 'm_sysinfo')
1295+ self.m_sysinfo.return_value = {'dist': ('Distro', '99.1', 'Codename')}
1296
1297- def _get_cloud(self, distro):
1298- self.patchUtils(self.new_root)
1299+ def _get_cloud(self, distro, sys_cfg=None):
1300+ self.new_root = self.reRoot(root=self.new_root)
1301 paths = helpers.Paths({'templates_dir': self.new_root})
1302 cls = distros.fetch(distro)
1303- mydist = cls(distro, {}, paths)
1304- myds = DataSourceNone.DataSourceNone({}, mydist, paths)
1305- return cloud.Cloud(myds, paths, {}, mydist, None)
1306+ if not sys_cfg:
1307+ sys_cfg = {}
1308+ mydist = cls(distro, sys_cfg, paths)
1309+ myds = DataSourceNone.DataSourceNone(sys_cfg, mydist, paths)
1310+ return cloud.Cloud(myds, paths, sys_cfg, mydist, None)
1311+
1312+ def _get_template_path(self, template_name, distro, basepath=None):
1313+ # ntp.conf.{distro} -> ntp.conf.debian.tmpl
1314+ template_fn = '{0}.tmpl'.format(
1315+ template_name.replace('{distro}', distro))
1316+ if not basepath:
1317+ basepath = self.new_root
1318+ path = os.path.join(basepath, template_fn)
1319+ return path
1320+
1321+ def _generate_template(self, template=None):
1322+ if not template:
1323+ template = NTP_TEMPLATE
1324+ confpath = os.path.join(self.new_root, 'client.conf')
1325+ template_fn = os.path.join(self.new_root, 'client.conf.tmpl')
1326+ util.write_file(template_fn, content=template)
1327+ return (confpath, template_fn)
1328+
1329+ def _mock_ntp_client_config(self, client=None, distro=None):
1330+ if not client:
1331+ client = 'ntp'
1332+ if not distro:
1333+ distro = 'ubuntu'
1334+ dcfg = cc_ntp.distro_ntp_client_configs(distro)
1335+ if client == 'systemd-timesyncd':
1336+ template = TIMESYNCD_TEMPLATE
1337+ else:
1338+ template = NTP_TEMPLATE
1339+ (confpath, template_fn) = self._generate_template(template=template)
1340+ ntpconfig = copy.deepcopy(dcfg[client])
1341+ ntpconfig['confpath'] = confpath
1342+ ntpconfig['template_name'] = os.path.basename(confpath)
1343+ return ntpconfig
1344
1345 @mock.patch("cloudinit.config.cc_ntp.util")
1346 def test_ntp_install(self, mock_util):
1347- """ntp_install installs via install_func when check_exe is absent."""
1348+ """ntp_install_client runs install_func when check_exe is absent."""
1349 mock_util.which.return_value = None # check_exe not found.
1350 install_func = mock.MagicMock()
1351- cc_ntp.install_ntp(install_func, packages=['ntpx'], check_exe='ntpdx')
1352-
1353+ cc_ntp.install_ntp_client(install_func,
1354+ packages=['ntpx'], check_exe='ntpdx')
1355 mock_util.which.assert_called_with('ntpdx')
1356 install_func.assert_called_once_with(['ntpx'])
1357
1358 @mock.patch("cloudinit.config.cc_ntp.util")
1359 def test_ntp_install_not_needed(self, mock_util):
1360- """ntp_install doesn't attempt install when check_exe is found."""
1361- mock_util.which.return_value = ["/usr/sbin/ntpd"] # check_exe found.
1362+ """ntp_install_client doesn't install when check_exe is found."""
1363+ client = 'chrony'
1364+ mock_util.which.return_value = [client] # check_exe found.
1365 install_func = mock.MagicMock()
1366- cc_ntp.install_ntp(install_func, packages=['ntp'], check_exe='ntpd')
1367+ cc_ntp.install_ntp_client(install_func, packages=[client],
1368+ check_exe=client)
1369 install_func.assert_not_called()
1370
1371 @mock.patch("cloudinit.config.cc_ntp.util")
1372 def test_ntp_install_no_op_with_empty_pkg_list(self, mock_util):
1373- """ntp_install calls install_func with empty list"""
1374+ """ntp_install_client runs install_func with empty list"""
1375 mock_util.which.return_value = None # check_exe not found
1376 install_func = mock.MagicMock()
1377- cc_ntp.install_ntp(install_func, packages=[], check_exe='timesyncd')
1378+ cc_ntp.install_ntp_client(install_func, packages=[],
1379+ check_exe='timesyncd')
1380 install_func.assert_called_once_with([])
1381
1382- def test_ntp_rename_ntp_conf(self):
1383- """When NTP_CONF exists, rename_ntp moves it."""
1384- ntpconf = self.tmp_path("ntp.conf", self.new_root)
1385- util.write_file(ntpconf, "")
1386- with mock.patch("cloudinit.config.cc_ntp.NTP_CONF", ntpconf):
1387- cc_ntp.rename_ntp_conf()
1388- self.assertFalse(os.path.exists(ntpconf))
1389- self.assertTrue(os.path.exists("{0}.dist".format(ntpconf)))
1390-
1391 @mock.patch("cloudinit.config.cc_ntp.util")
1392 def test_reload_ntp_defaults(self, mock_util):
1393 """Test service is restarted/reloaded (defaults)"""
1394- service = 'ntp'
1395+ service = 'ntp_service_name'
1396 cmd = ['service', service, 'restart']
1397 cc_ntp.reload_ntp(service)
1398 mock_util.subp.assert_called_with(cmd, capture=True)
1399@@ -89,193 +122,171 @@ class TestNtp(FilesystemMockingTestCase):
1400 @mock.patch("cloudinit.config.cc_ntp.util")
1401 def test_reload_ntp_systemd(self, mock_util):
1402 """Test service is restarted/reloaded (systemd)"""
1403- service = 'ntp'
1404- cmd = ['systemctl', 'reload-or-restart', service]
1405+ service = 'ntp_service_name'
1406 cc_ntp.reload_ntp(service, systemd=True)
1407- mock_util.subp.assert_called_with(cmd, capture=True)
1408-
1409- @mock.patch("cloudinit.config.cc_ntp.util")
1410- def test_reload_ntp_systemd_timesycnd(self, mock_util):
1411- """Test service is restarted/reloaded (systemd/timesyncd)"""
1412- service = 'systemd-timesycnd'
1413 cmd = ['systemctl', 'reload-or-restart', service]
1414- cc_ntp.reload_ntp(service, systemd=True)
1415 mock_util.subp.assert_called_with(cmd, capture=True)
1416
1417+ def test_ntp_rename_ntp_conf(self):
1418+ """When NTP_CONF exists, rename_ntp moves it."""
1419+ ntpconf = self.tmp_path("ntp.conf", self.new_root)
1420+ util.write_file(ntpconf, "")
1421+ cc_ntp.rename_ntp_conf(confpath=ntpconf)
1422+ self.assertFalse(os.path.exists(ntpconf))
1423+ self.assertTrue(os.path.exists("{0}.dist".format(ntpconf)))
1424+
1425 def test_ntp_rename_ntp_conf_skip_missing(self):
1426 """When NTP_CONF doesn't exist rename_ntp doesn't create a file."""
1427 ntpconf = self.tmp_path("ntp.conf", self.new_root)
1428 self.assertFalse(os.path.exists(ntpconf))
1429- with mock.patch("cloudinit.config.cc_ntp.NTP_CONF", ntpconf):
1430- cc_ntp.rename_ntp_conf()
1431+ cc_ntp.rename_ntp_conf(confpath=ntpconf)
1432 self.assertFalse(os.path.exists("{0}.dist".format(ntpconf)))
1433 self.assertFalse(os.path.exists(ntpconf))
1434
1435- def test_write_ntp_config_template_from_ntp_conf_tmpl_with_servers(self):
1436- """write_ntp_config_template reads content from ntp.conf.tmpl.
1437-
1438- It reads ntp.conf.tmpl if present and renders the value from servers
1439- key. When no pools key is defined, template is rendered using an empty
1440- list for pools.
1441- """
1442- distro = 'ubuntu'
1443- cfg = {
1444- 'servers': ['192.168.2.1', '192.168.2.2']
1445- }
1446- mycloud = self._get_cloud(distro)
1447- ntp_conf = self.tmp_path("ntp.conf", self.new_root) # Doesn't exist
1448- # Create ntp.conf.tmpl
1449- with open('{0}.tmpl'.format(ntp_conf), 'wb') as stream:
1450- stream.write(NTP_TEMPLATE)
1451- with mock.patch('cloudinit.config.cc_ntp.NTP_CONF', ntp_conf):
1452- cc_ntp.write_ntp_config_template(cfg, mycloud, ntp_conf)
1453- content = util.read_file_or_url('file://' + ntp_conf).contents
1454+ def test_write_ntp_config_template_uses_ntp_conf_distro_no_servers(self):
1455+ """write_ntp_config_template reads from $client.conf.distro.tmpl"""
1456+ servers = []
1457+ pools = ['10.0.0.1', '10.0.0.2']
1458+ (confpath, template_fn) = self._generate_template()
1459+ mock_path = 'cloudinit.config.cc_ntp.temp_utils._TMPDIR'
1460+ with mock.patch(mock_path, self.new_root):
1461+ cc_ntp.write_ntp_config_template('ubuntu',
1462+ servers=servers, pools=pools,
1463+ path=confpath,
1464+ template_fn=template_fn,
1465+ template=None)
1466+ content = util.read_file_or_url('file://' + confpath).contents
1467 self.assertEqual(
1468- "servers ['192.168.2.1', '192.168.2.2']\npools []\n",
1469- content.decode())
1470+ "servers []\npools ['10.0.0.1', '10.0.0.2']\n", content.decode())
1471
1472- def test_write_ntp_config_template_uses_ntp_conf_distro_no_servers(self):
1473- """write_ntp_config_template reads content from ntp.conf.distro.tmpl.
1474+ def test_write_ntp_config_template_defaults_pools_w_empty_lists(self):
1475+ """write_ntp_config_template defaults pools servers upon empty config.
1476
1477- It reads ntp.conf.<distro>.tmpl before attempting ntp.conf.tmpl. It
1478- renders the value from the keys servers and pools. When no
1479- servers value is present, template is rendered using an empty list.
1480+ When both pools and servers are empty, default NR_POOL_SERVERS get
1481+ configured.
1482 """
1483 distro = 'ubuntu'
1484- cfg = {
1485- 'pools': ['10.0.0.1', '10.0.0.2']
1486- }
1487- mycloud = self._get_cloud(distro)
1488- ntp_conf = self.tmp_path('ntp.conf', self.new_root) # Doesn't exist
1489- # Create ntp.conf.tmpl which isn't read
1490- with open('{0}.tmpl'.format(ntp_conf), 'wb') as stream:
1491- stream.write(b'NOT READ: ntp.conf.<distro>.tmpl is primary')
1492- # Create ntp.conf.tmpl.<distro>
1493- with open('{0}.{1}.tmpl'.format(ntp_conf, distro), 'wb') as stream:
1494- stream.write(NTP_TEMPLATE)
1495- with mock.patch('cloudinit.config.cc_ntp.NTP_CONF', ntp_conf):
1496- cc_ntp.write_ntp_config_template(cfg, mycloud, ntp_conf)
1497- content = util.read_file_or_url('file://' + ntp_conf).contents
1498+ pools = cc_ntp.generate_server_names(distro)
1499+ servers = []
1500+ (confpath, template_fn) = self._generate_template()
1501+ mock_path = 'cloudinit.config.cc_ntp.temp_utils._TMPDIR'
1502+ with mock.patch(mock_path, self.new_root):
1503+ cc_ntp.write_ntp_config_template(distro,
1504+ servers=servers, pools=pools,
1505+ path=confpath,
1506+ template_fn=template_fn,
1507+ template=None)
1508+ content = util.read_file_or_url('file://' + confpath).contents
1509 self.assertEqual(
1510- "servers []\npools ['10.0.0.1', '10.0.0.2']\n",
1511+ "servers []\npools {0}\n".format(pools),
1512 content.decode())
1513
1514- def test_write_ntp_config_template_defaults_pools_when_empty_lists(self):
1515- """write_ntp_config_template defaults pools servers upon empty config.
1516+ def test_defaults_pools_empty_lists_sles(self):
1517+ """write_ntp_config_template defaults opensuse pools upon empty config.
1518
1519 When both pools and servers are empty, default NR_POOL_SERVERS get
1520 configured.
1521 """
1522- distro = 'ubuntu'
1523- mycloud = self._get_cloud(distro)
1524- ntp_conf = self.tmp_path('ntp.conf', self.new_root) # Doesn't exist
1525- # Create ntp.conf.tmpl
1526- with open('{0}.tmpl'.format(ntp_conf), 'wb') as stream:
1527- stream.write(NTP_TEMPLATE)
1528- with mock.patch('cloudinit.config.cc_ntp.NTP_CONF', ntp_conf):
1529- cc_ntp.write_ntp_config_template({}, mycloud, ntp_conf)
1530- content = util.read_file_or_url('file://' + ntp_conf).contents
1531- default_pools = [
1532- "{0}.{1}.pool.ntp.org".format(x, distro)
1533- for x in range(0, cc_ntp.NR_POOL_SERVERS)]
1534+ distro = 'sles'
1535+ default_pools = cc_ntp.generate_server_names(distro)
1536+ (confpath, template_fn) = self._generate_template()
1537+
1538+ cc_ntp.write_ntp_config_template(distro,
1539+ servers=[], pools=[],
1540+ path=confpath,
1541+ template_fn=template_fn,
1542+ template=None)
1543+ content = util.read_file_or_url('file://' + confpath).contents
1544+ for pool in default_pools:
1545+ self.assertIn('opensuse', pool)
1546 self.assertEqual(
1547- "servers []\npools {0}\n".format(default_pools),
1548- content.decode())
1549+ "servers []\npools {0}\n".format(default_pools), content.decode())
1550 self.assertIn(
1551 "Adding distro default ntp pool servers: {0}".format(
1552 ",".join(default_pools)),
1553 self.logs.getvalue())
1554
1555- @mock.patch("cloudinit.config.cc_ntp.ntp_installable")
1556- def test_ntp_handler_mocked_template(self, m_ntp_install):
1557- """Test ntp handler renders ubuntu ntp.conf template."""
1558- pools = ['0.mycompany.pool.ntp.org', '3.mycompany.pool.ntp.org']
1559- servers = ['192.168.23.3', '192.168.23.4']
1560- cfg = {
1561- 'ntp': {
1562- 'pools': pools,
1563- 'servers': servers
1564- }
1565- }
1566- mycloud = self._get_cloud('ubuntu')
1567- ntp_conf = self.tmp_path('ntp.conf', self.new_root) # Doesn't exist
1568- m_ntp_install.return_value = True
1569-
1570- # Create ntp.conf.tmpl
1571- with open('{0}.tmpl'.format(ntp_conf), 'wb') as stream:
1572- stream.write(NTP_TEMPLATE)
1573- with mock.patch('cloudinit.config.cc_ntp.NTP_CONF', ntp_conf):
1574- with mock.patch.object(util, 'which', return_value=None):
1575- cc_ntp.handle('notimportant', cfg, mycloud, None, None)
1576-
1577- content = util.read_file_or_url('file://' + ntp_conf).contents
1578- self.assertEqual(
1579- 'servers {0}\npools {1}\n'.format(servers, pools),
1580- content.decode())
1581-
1582- @mock.patch("cloudinit.config.cc_ntp.util")
1583- def test_ntp_handler_mocked_template_snappy(self, m_util):
1584- """Test ntp handler renders timesycnd.conf template on snappy."""
1585+ def test_timesyncd_template(self):
1586+ """Test timesycnd template is correct"""
1587 pools = ['0.mycompany.pool.ntp.org', '3.mycompany.pool.ntp.org']
1588 servers = ['192.168.23.3', '192.168.23.4']
1589- cfg = {
1590- 'ntp': {
1591- 'pools': pools,
1592- 'servers': servers
1593- }
1594- }
1595- mycloud = self._get_cloud('ubuntu')
1596- m_util.system_is_snappy.return_value = True
1597-
1598- # Create timesyncd.conf.tmpl
1599- tsyncd_conf = self.tmp_path("timesyncd.conf", self.new_root)
1600- template = '{0}.tmpl'.format(tsyncd_conf)
1601- with open(template, 'wb') as stream:
1602- stream.write(TIMESYNCD_TEMPLATE)
1603-
1604- with mock.patch('cloudinit.config.cc_ntp.TIMESYNCD_CONF', tsyncd_conf):
1605- cc_ntp.handle('notimportant', cfg, mycloud, None, None)
1606-
1607- content = util.read_file_or_url('file://' + tsyncd_conf).contents
1608+ (confpath, template_fn) = self._generate_template(
1609+ template=TIMESYNCD_TEMPLATE)
1610+ cc_ntp.write_ntp_config_template('ubuntu',
1611+ servers=servers, pools=pools,
1612+ path=confpath,
1613+ template_fn=template_fn,
1614+ template=None)
1615+ content = util.read_file_or_url('file://' + confpath).contents
1616 self.assertEqual(
1617 "[Time]\nNTP=%s %s \n" % (" ".join(servers), " ".join(pools)),
1618 content.decode())
1619
1620- def test_ntp_handler_real_distro_templates(self):
1621- """Test ntp handler renders the shipped distro ntp.conf templates."""
1622+ def test_distro_ntp_client_configs(self):
1623+ """Test we have updated ntp client configs on different distros"""
1624+ delta = copy.deepcopy(cc_ntp.DISTRO_CLIENT_CONFIG)
1625+ base = copy.deepcopy(cc_ntp.NTP_CLIENT_CONFIG)
1626+ # confirm no-delta distros match the base config
1627+ for distro in cc_ntp.distros:
1628+ if distro not in delta:
1629+ result = cc_ntp.distro_ntp_client_configs(distro)
1630+ self.assertEqual(base, result)
1631+ # for distros with delta, ensure the merged config values match
1632+ # what is set in the delta
1633+ for distro in delta.keys():
1634+ result = cc_ntp.distro_ntp_client_configs(distro)
1635+ for client in delta[distro].keys():
1636+ for key in delta[distro][client].keys():
1637+ self.assertEqual(delta[distro][client][key],
1638+ result[client][key])
1639+
1640+ def test_ntp_handler_real_distro_ntp_templates(self):
1641+ """Test ntp handler renders the shipped distro ntp client templates."""
1642 pools = ['0.mycompany.pool.ntp.org', '3.mycompany.pool.ntp.org']
1643 servers = ['192.168.23.3', '192.168.23.4']
1644- cfg = {
1645- 'ntp': {
1646- 'pools': pools,
1647- 'servers': servers
1648- }
1649- }
1650- ntp_conf = self.tmp_path('ntp.conf', self.new_root) # Doesn't exist
1651- for distro in ('debian', 'ubuntu', 'fedora', 'rhel', 'sles'):
1652- mycloud = self._get_cloud(distro)
1653- root_dir = dirname(dirname(os.path.realpath(util.__file__)))
1654- tmpl_file = os.path.join(
1655- '{0}/templates/ntp.conf.{1}.tmpl'.format(root_dir, distro))
1656- # Create a copy in our tmp_dir
1657- shutil.copy(
1658- tmpl_file,
1659- os.path.join(self.new_root, 'ntp.conf.%s.tmpl' % distro))
1660- with mock.patch('cloudinit.config.cc_ntp.NTP_CONF', ntp_conf):
1661- with mock.patch.object(util, 'which', return_value=[True]):
1662- cc_ntp.handle('notimportant', cfg, mycloud, None, None)
1663-
1664- content = util.read_file_or_url('file://' + ntp_conf).contents
1665- expected_servers = '\n'.join([
1666- 'server {0} iburst'.format(server) for server in servers])
1667- self.assertIn(
1668- expected_servers, content.decode(),
1669- 'failed to render ntp.conf for distro:{0}'.format(distro))
1670- expected_pools = '\n'.join([
1671- 'pool {0} iburst'.format(pool) for pool in pools])
1672- self.assertIn(
1673- expected_pools, content.decode(),
1674- 'failed to render ntp.conf for distro:{0}'.format(distro))
1675+ for client in ['ntp', 'systemd-timesyncd', 'chrony']:
1676+ for distro in cc_ntp.distros:
1677+ distro_cfg = cc_ntp.distro_ntp_client_configs(distro)
1678+ ntpclient = distro_cfg[client]
1679+ confpath = (
1680+ os.path.join(self.new_root, ntpclient.get('confpath')[1:]))
1681+ template = ntpclient.get('template_name')
1682+ # find sourcetree template file
1683+ root_dir = (
1684+ dirname(dirname(os.path.realpath(util.__file__))) +
1685+ '/templates')
1686+ source_fn = self._get_template_path(template, distro,
1687+ basepath=root_dir)
1688+ template_fn = self._get_template_path(template, distro)
1689+ # don't fail if cloud-init doesn't have a template for
1690+ # a distro,client pair
1691+ if not os.path.exists(source_fn):
1692+ continue
1693+ # Create a copy in our tmp_dir
1694+ shutil.copy(source_fn, template_fn)
1695+ cc_ntp.write_ntp_config_template(distro, servers=servers,
1696+ pools=pools, path=confpath,
1697+ template_fn=template_fn)
1698+ content = util.read_file_or_url('file://' + confpath).contents
1699+ if client in ['ntp', 'chrony']:
1700+ expected_servers = '\n'.join([
1701+ 'server {0} iburst'.format(srv) for srv in servers])
1702+ print('distro=%s client=%s' % (distro, client))
1703+ self.assertIn(expected_servers, content.decode('utf-8'),
1704+ ('failed to render {0} conf'
1705+ ' for distro:{1}'.format(client, distro)))
1706+ expected_pools = '\n'.join([
1707+ 'pool {0} iburst'.format(pool) for pool in pools])
1708+ self.assertIn(expected_pools, content.decode('utf-8'),
1709+ ('failed to render {0} conf'
1710+ ' for distro:{1}'.format(client, distro)))
1711+ elif client == 'systemd-timesyncd':
1712+ expected_content = (
1713+ "# cloud-init generated file\n" +
1714+ "# See timesyncd.conf(5) for details.\n\n" +
1715+ "[Time]\nNTP=%s %s \n" % (" ".join(servers),
1716+ " ".join(pools)))
1717+ self.assertEqual(expected_content, content.decode())
1718
1719 def test_no_ntpcfg_does_nothing(self):
1720 """When no ntp section is defined handler logs a warning and noops."""
1721@@ -285,95 +296,99 @@ class TestNtp(FilesystemMockingTestCase):
1722 'not present or disabled by cfg\n',
1723 self.logs.getvalue())
1724
1725- def test_ntp_handler_schema_validation_allows_empty_ntp_config(self):
1726+ @mock.patch('cloudinit.config.cc_ntp.select_ntp_client')
1727+ def test_ntp_handler_schema_validation_allows_empty_ntp_config(self,
1728+ m_select):
1729 """Ntp schema validation allows for an empty ntp: configuration."""
1730 valid_empty_configs = [{'ntp': {}}, {'ntp': None}]
1731- distro = 'ubuntu'
1732- cc = self._get_cloud(distro)
1733- ntp_conf = os.path.join(self.new_root, 'ntp.conf')
1734- with open('{0}.tmpl'.format(ntp_conf), 'wb') as stream:
1735- stream.write(NTP_TEMPLATE)
1736 for valid_empty_config in valid_empty_configs:
1737- with mock.patch('cloudinit.config.cc_ntp.NTP_CONF', ntp_conf):
1738- cc_ntp.handle('cc_ntp', valid_empty_config, cc, None, [])
1739- with open(ntp_conf) as stream:
1740- content = stream.read()
1741- default_pools = [
1742- "{0}.{1}.pool.ntp.org".format(x, distro)
1743- for x in range(0, cc_ntp.NR_POOL_SERVERS)]
1744- self.assertEqual(
1745- "servers []\npools {0}\n".format(default_pools),
1746- content)
1747- self.assertNotIn('Invalid config:', self.logs.getvalue())
1748+ for distro in cc_ntp.distros:
1749+ mycloud = self._get_cloud(distro)
1750+ ntpconfig = self._mock_ntp_client_config(distro=distro)
1751+ confpath = ntpconfig['confpath']
1752+ m_select.return_value = ntpconfig
1753+ cc_ntp.handle('cc_ntp', valid_empty_config, mycloud, None, [])
1754+ content = util.read_file_or_url('file://' + confpath).contents
1755+ pools = cc_ntp.generate_server_names(mycloud.distro.name)
1756+ self.assertEqual(
1757+ "servers []\npools {0}\n".format(pools), content.decode())
1758+ self.assertNotIn('Invalid config:', self.logs.getvalue())
1759
1760 @skipUnlessJsonSchema()
1761- def test_ntp_handler_schema_validation_warns_non_string_item_type(self):
1762+ @mock.patch('cloudinit.config.cc_ntp.select_ntp_client')
1763+ def test_ntp_handler_schema_validation_warns_non_string_item_type(self,
1764+ m_sel):
1765 """Ntp schema validation warns of non-strings in pools or servers.
1766
1767 Schema validation is not strict, so ntp config is still be rendered.
1768 """
1769 invalid_config = {'ntp': {'pools': [123], 'servers': ['valid', None]}}
1770- cc = self._get_cloud('ubuntu')
1771- ntp_conf = os.path.join(self.new_root, 'ntp.conf')
1772- with open('{0}.tmpl'.format(ntp_conf), 'wb') as stream:
1773- stream.write(NTP_TEMPLATE)
1774- with mock.patch('cloudinit.config.cc_ntp.NTP_CONF', ntp_conf):
1775- cc_ntp.handle('cc_ntp', invalid_config, cc, None, [])
1776- self.assertIn(
1777- "Invalid config:\nntp.pools.0: 123 is not of type 'string'\n"
1778- "ntp.servers.1: None is not of type 'string'",
1779- self.logs.getvalue())
1780- with open(ntp_conf) as stream:
1781- content = stream.read()
1782- self.assertEqual("servers ['valid', None]\npools [123]\n", content)
1783+ for distro in cc_ntp.distros:
1784+ mycloud = self._get_cloud(distro)
1785+ ntpconfig = self._mock_ntp_client_config(distro=distro)
1786+ confpath = ntpconfig['confpath']
1787+ m_sel.return_value = ntpconfig
1788+ cc_ntp.handle('cc_ntp', invalid_config, mycloud, None, [])
1789+ self.assertIn(
1790+ "Invalid config:\nntp.pools.0: 123 is not of type 'string'\n"
1791+ "ntp.servers.1: None is not of type 'string'",
1792+ self.logs.getvalue())
1793+ content = util.read_file_or_url('file://' + confpath).contents
1794+ self.assertEqual("servers ['valid', None]\npools [123]\n",
1795+ content.decode())
1796
1797 @skipUnlessJsonSchema()
1798- def test_ntp_handler_schema_validation_warns_of_non_array_type(self):
1799+ @mock.patch('cloudinit.config.cc_ntp.select_ntp_client')
1800+ def test_ntp_handler_schema_validation_warns_of_non_array_type(self,
1801+ m_select):
1802 """Ntp schema validation warns of non-array pools or servers types.
1803
1804 Schema validation is not strict, so ntp config is still be rendered.
1805 """
1806 invalid_config = {'ntp': {'pools': 123, 'servers': 'non-array'}}
1807- cc = self._get_cloud('ubuntu')
1808- ntp_conf = os.path.join(self.new_root, 'ntp.conf')
1809- with open('{0}.tmpl'.format(ntp_conf), 'wb') as stream:
1810- stream.write(NTP_TEMPLATE)
1811- with mock.patch('cloudinit.config.cc_ntp.NTP_CONF', ntp_conf):
1812- cc_ntp.handle('cc_ntp', invalid_config, cc, None, [])
1813- self.assertIn(
1814- "Invalid config:\nntp.pools: 123 is not of type 'array'\n"
1815- "ntp.servers: 'non-array' is not of type 'array'",
1816- self.logs.getvalue())
1817- with open(ntp_conf) as stream:
1818- content = stream.read()
1819- self.assertEqual("servers non-array\npools 123\n", content)
1820+
1821+ for distro in cc_ntp.distros:
1822+ mycloud = self._get_cloud(distro)
1823+ ntpconfig = self._mock_ntp_client_config(distro=distro)
1824+ confpath = ntpconfig['confpath']
1825+ m_select.return_value = ntpconfig
1826+ cc_ntp.handle('cc_ntp', invalid_config, mycloud, None, [])
1827+ self.assertIn(
1828+ "Invalid config:\nntp.pools: 123 is not of type 'array'\n"
1829+ "ntp.servers: 'non-array' is not of type 'array'",
1830+ self.logs.getvalue())
1831+ content = util.read_file_or_url('file://' + confpath).contents
1832+ self.assertEqual("servers non-array\npools 123\n",
1833+ content.decode())
1834
1835 @skipUnlessJsonSchema()
1836- def test_ntp_handler_schema_validation_warns_invalid_key_present(self):
1837+ @mock.patch('cloudinit.config.cc_ntp.select_ntp_client')
1838+ def test_ntp_handler_schema_validation_warns_invalid_key_present(self,
1839+ m_select):
1840 """Ntp schema validation warns of invalid keys present in ntp config.
1841
1842 Schema validation is not strict, so ntp config is still be rendered.
1843 """
1844 invalid_config = {
1845 'ntp': {'invalidkey': 1, 'pools': ['0.mycompany.pool.ntp.org']}}
1846- cc = self._get_cloud('ubuntu')
1847- ntp_conf = os.path.join(self.new_root, 'ntp.conf')
1848- with open('{0}.tmpl'.format(ntp_conf), 'wb') as stream:
1849- stream.write(NTP_TEMPLATE)
1850- with mock.patch('cloudinit.config.cc_ntp.NTP_CONF', ntp_conf):
1851- cc_ntp.handle('cc_ntp', invalid_config, cc, None, [])
1852- self.assertIn(
1853- "Invalid config:\nntp: Additional properties are not allowed "
1854- "('invalidkey' was unexpected)",
1855- self.logs.getvalue())
1856- with open(ntp_conf) as stream:
1857- content = stream.read()
1858- self.assertEqual(
1859- "servers []\npools ['0.mycompany.pool.ntp.org']\n",
1860- content)
1861+ for distro in cc_ntp.distros:
1862+ mycloud = self._get_cloud(distro)
1863+ ntpconfig = self._mock_ntp_client_config(distro=distro)
1864+ confpath = ntpconfig['confpath']
1865+ m_select.return_value = ntpconfig
1866+ cc_ntp.handle('cc_ntp', invalid_config, mycloud, None, [])
1867+ self.assertIn(
1868+ "Invalid config:\nntp: Additional properties are not allowed "
1869+ "('invalidkey' was unexpected)",
1870+ self.logs.getvalue())
1871+ content = util.read_file_or_url('file://' + confpath).contents
1872+ self.assertEqual(
1873+ "servers []\npools ['0.mycompany.pool.ntp.org']\n",
1874+ content.decode())
1875
1876 @skipUnlessJsonSchema()
1877- def test_ntp_handler_schema_validation_warns_of_duplicates(self):
1878+ @mock.patch('cloudinit.config.cc_ntp.select_ntp_client')
1879+ def test_ntp_handler_schema_validation_warns_of_duplicates(self, m_select):
1880 """Ntp schema validation warns of duplicates in servers or pools.
1881
1882 Schema validation is not strict, so ntp config is still be rendered.
1883@@ -381,74 +396,334 @@ class TestNtp(FilesystemMockingTestCase):
1884 invalid_config = {
1885 'ntp': {'pools': ['0.mypool.org', '0.mypool.org'],
1886 'servers': ['10.0.0.1', '10.0.0.1']}}
1887- cc = self._get_cloud('ubuntu')
1888- ntp_conf = os.path.join(self.new_root, 'ntp.conf')
1889- with open('{0}.tmpl'.format(ntp_conf), 'wb') as stream:
1890- stream.write(NTP_TEMPLATE)
1891- with mock.patch('cloudinit.config.cc_ntp.NTP_CONF', ntp_conf):
1892- cc_ntp.handle('cc_ntp', invalid_config, cc, None, [])
1893- self.assertIn(
1894- "Invalid config:\nntp.pools: ['0.mypool.org', '0.mypool.org'] has "
1895- "non-unique elements\nntp.servers: ['10.0.0.1', '10.0.0.1'] has "
1896- "non-unique elements",
1897- self.logs.getvalue())
1898- with open(ntp_conf) as stream:
1899- content = stream.read()
1900- self.assertEqual(
1901- "servers ['10.0.0.1', '10.0.0.1']\n"
1902- "pools ['0.mypool.org', '0.mypool.org']\n",
1903- content)
1904+ for distro in cc_ntp.distros:
1905+ mycloud = self._get_cloud(distro)
1906+ ntpconfig = self._mock_ntp_client_config(distro=distro)
1907+ confpath = ntpconfig['confpath']
1908+ m_select.return_value = ntpconfig
1909+ cc_ntp.handle('cc_ntp', invalid_config, mycloud, None, [])
1910+ self.assertIn(
1911+ "Invalid config:\nntp.pools: ['0.mypool.org', '0.mypool.org']"
1912+ " has non-unique elements\nntp.servers: "
1913+ "['10.0.0.1', '10.0.0.1'] has non-unique elements",
1914+ self.logs.getvalue())
1915+ content = util.read_file_or_url('file://' + confpath).contents
1916+ self.assertEqual(
1917+ "servers ['10.0.0.1', '10.0.0.1']\n"
1918+ "pools ['0.mypool.org', '0.mypool.org']\n", content.decode())
1919
1920- @mock.patch("cloudinit.config.cc_ntp.ntp_installable")
1921- def test_ntp_handler_timesyncd(self, m_ntp_install):
1922+ @mock.patch('cloudinit.config.cc_ntp.select_ntp_client')
1923+ def test_ntp_handler_timesyncd(self, m_select):
1924 """Test ntp handler configures timesyncd"""
1925- m_ntp_install.return_value = False
1926- distro = 'ubuntu'
1927- cfg = {
1928- 'servers': ['192.168.2.1', '192.168.2.2'],
1929- 'pools': ['0.mypool.org'],
1930- }
1931- mycloud = self._get_cloud(distro)
1932- tsyncd_conf = self.tmp_path("timesyncd.conf", self.new_root)
1933- # Create timesyncd.conf.tmpl
1934- template = '{0}.tmpl'.format(tsyncd_conf)
1935- print(template)
1936- with open(template, 'wb') as stream:
1937- stream.write(TIMESYNCD_TEMPLATE)
1938- with mock.patch('cloudinit.config.cc_ntp.TIMESYNCD_CONF', tsyncd_conf):
1939- cc_ntp.write_ntp_config_template(cfg, mycloud, tsyncd_conf,
1940- template='timesyncd.conf')
1941-
1942- content = util.read_file_or_url('file://' + tsyncd_conf).contents
1943- self.assertEqual(
1944- "[Time]\nNTP=192.168.2.1 192.168.2.2 0.mypool.org \n",
1945- content.decode())
1946+ servers = ['192.168.2.1', '192.168.2.2']
1947+ pools = ['0.mypool.org']
1948+ cfg = {'ntp': {'servers': servers, 'pools': pools}}
1949+ client = 'systemd-timesyncd'
1950+ for distro in cc_ntp.distros:
1951+ mycloud = self._get_cloud(distro)
1952+ ntpconfig = self._mock_ntp_client_config(distro=distro,
1953+ client=client)
1954+ confpath = ntpconfig['confpath']
1955+ m_select.return_value = ntpconfig
1956+ cc_ntp.handle('cc_ntp', cfg, mycloud, None, [])
1957+ content = util.read_file_or_url('file://' + confpath).contents
1958+ self.assertEqual(
1959+ "[Time]\nNTP=192.168.2.1 192.168.2.2 0.mypool.org \n",
1960+ content.decode())
1961+
1962+ @mock.patch('cloudinit.config.cc_ntp.select_ntp_client')
1963+ def test_ntp_handler_enabled_false(self, m_select):
1964+ """Test ntp handler does not run if enabled: false """
1965+ cfg = {'ntp': {'enabled': False}}
1966+ for distro in cc_ntp.distros:
1967+ mycloud = self._get_cloud(distro)
1968+ cc_ntp.handle('notimportant', cfg, mycloud, None, None)
1969+ self.assertEqual(0, m_select.call_count)
1970+
1971+ @mock.patch('cloudinit.config.cc_ntp.select_ntp_client')
1972+ @mock.patch("cloudinit.distros.Distro.uses_systemd")
1973+ def test_ntp_the_whole_package(self, m_sysd, m_select):
1974+ """Test enabled config renders template, and restarts service """
1975+ cfg = {'ntp': {'enabled': True}}
1976+ for distro in cc_ntp.distros:
1977+ mycloud = self._get_cloud(distro)
1978+ ntpconfig = self._mock_ntp_client_config(distro=distro)
1979+ confpath = ntpconfig['confpath']
1980+ service_name = ntpconfig['service_name']
1981+ m_select.return_value = ntpconfig
1982+ pools = cc_ntp.generate_server_names(mycloud.distro.name)
1983+ # force uses systemd path
1984+ m_sysd.return_value = True
1985+ with mock.patch('cloudinit.config.cc_ntp.util') as m_util:
1986+ # allow use of util.mergemanydict
1987+ m_util.mergemanydict.side_effect = util.mergemanydict
1988+ # default client is present
1989+ m_util.which.return_value = True
1990+ # use the config 'enabled' value
1991+ m_util.is_false.return_value = util.is_false(
1992+ cfg['ntp']['enabled'])
1993+ cc_ntp.handle('notimportant', cfg, mycloud, None, None)
1994+ m_util.subp.assert_called_with(
1995+ ['systemctl', 'reload-or-restart',
1996+ service_name], capture=True)
1997+ content = util.read_file_or_url('file://' + confpath).contents
1998+ self.assertEqual(
1999+ "servers []\npools {0}\n".format(pools),
2000+ content.decode())
2001+
2002+ def test_opensuse_picks_chrony(self):
2003+ """Test opensuse picks chrony or ntp on certain distro versions"""
2004+ # < 15.0 => ntp
2005+ self.m_sysinfo.return_value = {'dist':
2006+ ('openSUSE', '13.2', 'Harlequin')}
2007+ mycloud = self._get_cloud('opensuse')
2008+ expected_client = mycloud.distro.preferred_ntp_clients[0]
2009+ self.assertEqual('ntp', expected_client)
2010+
2011+ # >= 15.0 and not openSUSE => chrony
2012+ self.m_sysinfo.return_value = {'dist':
2013+ ('SLES', '15.0',
2014+ 'SUSE Linux Enterprise Server 15')}
2015+ mycloud = self._get_cloud('sles')
2016+ expected_client = mycloud.distro.preferred_ntp_clients[0]
2017+ self.assertEqual('chrony', expected_client)
2018+
2019+ # >= 15.0 and openSUSE and ver != 42 => chrony
2020+ self.m_sysinfo.return_value = {'dist': ('openSUSE Tumbleweed',
2021+ '20180326',
2022+ 'timbleweed')}
2023+ mycloud = self._get_cloud('opensuse')
2024+ expected_client = mycloud.distro.preferred_ntp_clients[0]
2025+ self.assertEqual('chrony', expected_client)
2026+
2027+ def test_ubuntu_xenial_picks_ntp(self):
2028+ """Test Ubuntu picks ntp on xenial release"""
2029+
2030+ self.m_sysinfo.return_value = {'dist': ('Ubuntu', '16.04', 'xenial')}
2031+ mycloud = self._get_cloud('ubuntu')
2032+ expected_client = mycloud.distro.preferred_ntp_clients[0]
2033+ self.assertEqual('ntp', expected_client)
2034
2035- def test_write_ntp_config_template_defaults_pools_empty_lists_sles(self):
2036- """write_ntp_config_template defaults pools servers upon empty config.
2037+ @mock.patch('cloudinit.config.cc_ntp.util.which')
2038+ def test_snappy_system_picks_timesyncd(self, m_which):
2039+ """Test snappy systems prefer installed clients"""
2040
2041- When both pools and servers are empty, default NR_POOL_SERVERS get
2042- configured.
2043- """
2044- distro = 'sles'
2045- mycloud = self._get_cloud(distro)
2046- ntp_conf = self.tmp_path('ntp.conf', self.new_root) # Doesn't exist
2047- # Create ntp.conf.tmpl
2048- with open('{0}.tmpl'.format(ntp_conf), 'wb') as stream:
2049- stream.write(NTP_TEMPLATE)
2050- with mock.patch('cloudinit.config.cc_ntp.NTP_CONF', ntp_conf):
2051- cc_ntp.write_ntp_config_template({}, mycloud, ntp_conf)
2052- content = util.read_file_or_url('file://' + ntp_conf).contents
2053- default_pools = [
2054- "{0}.opensuse.pool.ntp.org".format(x)
2055- for x in range(0, cc_ntp.NR_POOL_SERVERS)]
2056- self.assertEqual(
2057- "servers []\npools {0}\n".format(default_pools),
2058- content.decode())
2059- self.assertIn(
2060- "Adding distro default ntp pool servers: {0}".format(
2061- ",".join(default_pools)),
2062- self.logs.getvalue())
2063+ # we are on ubuntu-core here
2064+ self.m_snappy.return_value = True
2065
2066+ # ubuntu core systems will have timesyncd installed
2067+ m_which.side_effect = iter([None, '/lib/systemd/systemd-timesyncd',
2068+ None, None, None])
2069+ distro = 'ubuntu'
2070+ mycloud = self._get_cloud(distro)
2071+ distro_configs = cc_ntp.distro_ntp_client_configs(distro)
2072+ expected_client = 'systemd-timesyncd'
2073+ expected_cfg = distro_configs[expected_client]
2074+ expected_calls = []
2075+ # we only get to timesyncd
2076+ for client in mycloud.distro.preferred_ntp_clients[0:2]:
2077+ cfg = distro_configs[client]
2078+ expected_calls.append(mock.call(cfg['check_exe']))
2079+ result = cc_ntp.select_ntp_client(None, mycloud.distro)
2080+ m_which.assert_has_calls(expected_calls)
2081+ self.assertEqual(sorted(expected_cfg), sorted(cfg))
2082+ self.assertEqual(sorted(expected_cfg), sorted(result))
2083+
2084+ @mock.patch('cloudinit.config.cc_ntp.util.which')
2085+ def test_ntp_distro_searches_all_preferred_clients(self, m_which):
2086+ """Test select_ntp_client search all distro perferred clients """
2087+ # nothing is installed
2088+ m_which.return_value = None
2089+ for distro in cc_ntp.distros:
2090+ mycloud = self._get_cloud(distro)
2091+ distro_configs = cc_ntp.distro_ntp_client_configs(distro)
2092+ expected_client = mycloud.distro.preferred_ntp_clients[0]
2093+ expected_cfg = distro_configs[expected_client]
2094+ expected_calls = []
2095+ for client in mycloud.distro.preferred_ntp_clients:
2096+ cfg = distro_configs[client]
2097+ expected_calls.append(mock.call(cfg['check_exe']))
2098+ cc_ntp.select_ntp_client({}, mycloud.distro)
2099+ m_which.assert_has_calls(expected_calls)
2100+ self.assertEqual(sorted(expected_cfg), sorted(cfg))
2101+
2102+ @mock.patch('cloudinit.config.cc_ntp.util.which')
2103+ def test_user_cfg_ntp_client_auto_uses_distro_clients(self, m_which):
2104+ """Test user_cfg.ntp_client='auto' defaults to distro search"""
2105+ # nothing is installed
2106+ m_which.return_value = None
2107+ for distro in cc_ntp.distros:
2108+ mycloud = self._get_cloud(distro)
2109+ distro_configs = cc_ntp.distro_ntp_client_configs(distro)
2110+ expected_client = mycloud.distro.preferred_ntp_clients[0]
2111+ expected_cfg = distro_configs[expected_client]
2112+ expected_calls = []
2113+ for client in mycloud.distro.preferred_ntp_clients:
2114+ cfg = distro_configs[client]
2115+ expected_calls.append(mock.call(cfg['check_exe']))
2116+ cc_ntp.select_ntp_client('auto', mycloud.distro)
2117+ m_which.assert_has_calls(expected_calls)
2118+ self.assertEqual(sorted(expected_cfg), sorted(cfg))
2119+
2120+ @mock.patch('cloudinit.config.cc_ntp.write_ntp_config_template')
2121+ @mock.patch('cloudinit.cloud.Cloud.get_template_filename')
2122+ @mock.patch('cloudinit.config.cc_ntp.util.which')
2123+ def test_ntp_custom_client_overrides_installed_clients(self, m_which,
2124+ m_tmpfn, m_write):
2125+ """Test user client is installed despite other clients present """
2126+ client = 'ntpdate'
2127+ cfg = {'ntp': {'ntp_client': client}}
2128+ for distro in cc_ntp.distros:
2129+ # client is not installed
2130+ m_which.side_effect = iter([None])
2131+ mycloud = self._get_cloud(distro)
2132+ with mock.patch.object(mycloud.distro,
2133+ 'install_packages') as m_install:
2134+ cc_ntp.handle('notimportant', cfg, mycloud, None, None)
2135+ m_install.assert_called_with([client])
2136+ m_which.assert_called_with(client)
2137+
2138+ @mock.patch('cloudinit.config.cc_ntp.util.which')
2139+ def test_ntp_system_config_overrides_distro_builtin_clients(self, m_which):
2140+ """Test distro system_config overrides builtin preferred ntp clients"""
2141+ system_client = 'chrony'
2142+ sys_cfg = {'ntp_client': system_client}
2143+ # no clients installed
2144+ m_which.return_value = None
2145+ for distro in cc_ntp.distros:
2146+ mycloud = self._get_cloud(distro, sys_cfg=sys_cfg)
2147+ distro_configs = cc_ntp.distro_ntp_client_configs(distro)
2148+ expected_cfg = distro_configs[system_client]
2149+ result = cc_ntp.select_ntp_client(None, mycloud.distro)
2150+ self.assertEqual(sorted(expected_cfg), sorted(result))
2151+ m_which.assert_has_calls([])
2152+
2153+ @mock.patch('cloudinit.config.cc_ntp.util.which')
2154+ def test_ntp_user_config_overrides_system_cfg(self, m_which):
2155+ """Test user-data overrides system_config ntp_client"""
2156+ system_client = 'chrony'
2157+ sys_cfg = {'ntp_client': system_client}
2158+ user_client = 'systemd-timesyncd'
2159+ # no clients installed
2160+ m_which.return_value = None
2161+ for distro in cc_ntp.distros:
2162+ mycloud = self._get_cloud(distro, sys_cfg=sys_cfg)
2163+ distro_configs = cc_ntp.distro_ntp_client_configs(distro)
2164+ expected_cfg = distro_configs[user_client]
2165+ result = cc_ntp.select_ntp_client(user_client, mycloud.distro)
2166+ self.assertEqual(sorted(expected_cfg), sorted(result))
2167+ m_which.assert_has_calls([])
2168+
2169+ @mock.patch('cloudinit.config.cc_ntp.reload_ntp')
2170+ @mock.patch('cloudinit.config.cc_ntp.install_ntp_client')
2171+ def test_ntp_user_provided_config_with_template(self, m_install, m_reload):
2172+ custom = r'\n#MyCustomTemplate'
2173+ user_template = NTP_TEMPLATE + custom
2174+ confpath = os.path.join(self.new_root, 'etc/myntp/myntp.conf')
2175+ cfg = {
2176+ 'ntp': {
2177+ 'pools': ['mypool.org'],
2178+ 'ntp_client': 'myntpd',
2179+ 'config': {
2180+ 'check_exe': 'myntpd',
2181+ 'confpath': confpath,
2182+ 'packages': ['myntp'],
2183+ 'service_name': 'myntp',
2184+ 'template': user_template,
2185+ }
2186+ }
2187+ }
2188+ for distro in cc_ntp.distros:
2189+ mycloud = self._get_cloud(distro)
2190+ mock_path = 'cloudinit.config.cc_ntp.temp_utils._TMPDIR'
2191+ with mock.patch(mock_path, self.new_root):
2192+ cc_ntp.handle('notimportant', cfg, mycloud, None, None)
2193+ content = util.read_file_or_url('file://' + confpath).contents
2194+ self.assertEqual(
2195+ "servers []\npools ['mypool.org']\n%s" % custom,
2196+ content.decode())
2197+
2198+ @mock.patch('cloudinit.config.cc_ntp.supplemental_schema_validation')
2199+ @mock.patch('cloudinit.config.cc_ntp.reload_ntp')
2200+ @mock.patch('cloudinit.config.cc_ntp.install_ntp_client')
2201+ @mock.patch('cloudinit.config.cc_ntp.select_ntp_client')
2202+ def test_ntp_user_provided_config_template_only(self, m_select, m_install,
2203+ m_reload, m_schema):
2204+ """Test custom template for default client"""
2205+ custom = r'\n#MyCustomTemplate'
2206+ user_template = NTP_TEMPLATE + custom
2207+ client = 'chrony'
2208+ cfg = {
2209+ 'pools': ['mypool.org'],
2210+ 'ntp_client': client,
2211+ 'config': {
2212+ 'template': user_template,
2213+ }
2214+ }
2215+ expected_merged_cfg = {
2216+ 'check_exe': 'chronyd',
2217+ 'confpath': '{tmpdir}/client.conf'.format(tmpdir=self.new_root),
2218+ 'template_name': 'client.conf', 'template': user_template,
2219+ 'service_name': 'chrony', 'packages': ['chrony']}
2220+ for distro in cc_ntp.distros:
2221+ mycloud = self._get_cloud(distro)
2222+ ntpconfig = self._mock_ntp_client_config(client=client,
2223+ distro=distro)
2224+ confpath = ntpconfig['confpath']
2225+ m_select.return_value = ntpconfig
2226+ mock_path = 'cloudinit.config.cc_ntp.temp_utils._TMPDIR'
2227+ with mock.patch(mock_path, self.new_root):
2228+ cc_ntp.handle('notimportant',
2229+ {'ntp': cfg}, mycloud, None, None)
2230+ content = util.read_file_or_url('file://' + confpath).contents
2231+ self.assertEqual(
2232+ "servers []\npools ['mypool.org']\n%s" % custom,
2233+ content.decode())
2234+ m_schema.assert_called_with(expected_merged_cfg)
2235+
2236+
2237+class TestSupplementalSchemaValidation(CiTestCase):
2238+
2239+ def test_error_on_missing_keys(self):
2240+ """ValueError raised reporting any missing required ntp:config keys"""
2241+ cfg = {}
2242+ match = (r'Invalid ntp configuration:\\nMissing required ntp:config'
2243+ ' keys: check_exe, confpath, packages, service_name')
2244+ with self.assertRaisesRegex(ValueError, match):
2245+ cc_ntp.supplemental_schema_validation(cfg)
2246+
2247+ def test_error_requiring_either_template_or_template_name(self):
2248+ """ValueError raised if both template not template_name are None."""
2249+ cfg = {'confpath': 'someconf', 'check_exe': '', 'service_name': '',
2250+ 'template': None, 'template_name': None, 'packages': []}
2251+ match = (r'Invalid ntp configuration:\\nEither ntp:config:template'
2252+ ' or ntp:config:template_name values are required')
2253+ with self.assertRaisesRegex(ValueError, match):
2254+ cc_ntp.supplemental_schema_validation(cfg)
2255+
2256+ def test_error_on_non_list_values(self):
2257+ """ValueError raised when packages is not of type list."""
2258+ cfg = {'confpath': 'someconf', 'check_exe': '', 'service_name': '',
2259+ 'template': 'asdf', 'template_name': None, 'packages': 'NOPE'}
2260+ match = (r'Invalid ntp configuration:\\nExpected a list of required'
2261+ ' package names for ntp:config:packages. Found \(NOPE\)')
2262+ with self.assertRaisesRegex(ValueError, match):
2263+ cc_ntp.supplemental_schema_validation(cfg)
2264+
2265+ def test_error_on_non_string_values(self):
2266+ """ValueError raised for any values expected as string type."""
2267+ cfg = {'confpath': 1, 'check_exe': 2, 'service_name': 3,
2268+ 'template': 4, 'template_name': 5, 'packages': []}
2269+ errors = [
2270+ 'Expected a config file path ntp:config:confpath. Found (1)',
2271+ 'Expected a string type for ntp:config:check_exe. Found (2)',
2272+ 'Expected a string type for ntp:config:service_name. Found (3)',
2273+ 'Expected a string type for ntp:config:template. Found (4)',
2274+ 'Expected a string type for ntp:config:template_name. Found (5)']
2275+ with self.assertRaises(ValueError) as context_mgr:
2276+ cc_ntp.supplemental_schema_validation(cfg)
2277+ error_msg = str(context_mgr.exception)
2278+ for error in errors:
2279+ self.assertIn(error, error_msg)
2280
2281 # vi: ts=4 expandtab
2282diff --git a/tests/unittests/test_templating.py b/tests/unittests/test_templating.py
2283index 53154d3..1080e13 100644
2284--- a/tests/unittests/test_templating.py
2285+++ b/tests/unittests/test_templating.py
2286@@ -10,6 +10,7 @@ from cloudinit.tests import helpers as test_helpers
2287 import textwrap
2288
2289 from cloudinit import templater
2290+from cloudinit.util import load_file, write_file
2291
2292 try:
2293 import Cheetah
2294@@ -19,7 +20,17 @@ except ImportError:
2295 HAS_CHEETAH = False
2296
2297
2298-class TestTemplates(test_helpers.TestCase):
2299+class TestTemplates(test_helpers.CiTestCase):
2300+ jinja_utf8 = b'It\xe2\x80\x99s not ascii, {{name}}\n'
2301+ jinja_utf8_rbob = b'It\xe2\x80\x99s not ascii, bob\n'.decode('utf-8')
2302+
2303+ @staticmethod
2304+ def add_header(renderer, data):
2305+ """Return text (py2 unicode/py3 str) with template header."""
2306+ if isinstance(data, bytes):
2307+ data = data.decode('utf-8')
2308+ return "## template: %s\n" % renderer + data
2309+
2310 def test_render_basic(self):
2311 in_data = textwrap.dedent("""
2312 ${b}
2313@@ -106,4 +117,32 @@ $a,$b'''
2314 'codename': codename})
2315 self.assertEqual(ex_data, out_data)
2316
2317+ def test_jinja_nonascii_render_to_string(self):
2318+ """Test jinja render_to_string with non-ascii content."""
2319+ self.assertEqual(
2320+ templater.render_string(
2321+ self.add_header("jinja", self.jinja_utf8), {"name": "bob"}),
2322+ self.jinja_utf8_rbob)
2323+
2324+ def test_jinja_nonascii_render_to_file(self):
2325+ """Test jinja render_to_file of a filename with non-ascii content."""
2326+ tmpl_fn = self.tmp_path("j-render-to-file.template")
2327+ out_fn = self.tmp_path("j-render-to-file.out")
2328+ write_file(filename=tmpl_fn, omode="wb",
2329+ content=self.add_header(
2330+ "jinja", self.jinja_utf8).encode('utf-8'))
2331+ templater.render_to_file(tmpl_fn, out_fn, {"name": "bob"})
2332+ result = load_file(out_fn, decode=False).decode('utf-8')
2333+ self.assertEqual(result, self.jinja_utf8_rbob)
2334+
2335+ def test_jinja_nonascii_render_from_file(self):
2336+ """Test jinja render_from_file with non-ascii content."""
2337+ tmpl_fn = self.tmp_path("j-render-from-file.template")
2338+ write_file(tmpl_fn, omode="wb",
2339+ content=self.add_header(
2340+ "jinja", self.jinja_utf8).encode('utf-8'))
2341+ result = templater.render_from_file(tmpl_fn, {"name": "bob"})
2342+ self.assertEqual(result, self.jinja_utf8_rbob)
2343+
2344+
2345 # vi: ts=4 expandtab
2346diff --git a/tools/make-tarball b/tools/make-tarball
2347index 3197689..8d54013 100755
2348--- a/tools/make-tarball
2349+++ b/tools/make-tarball
2350@@ -13,22 +13,28 @@ Usage: ${0##*/} [revision]
2351 create a tarball of revision (default HEAD)
2352
2353 options:
2354- -o | --output FILE write to file
2355+ -h | --help print usage
2356+ -o | --output FILE write to file
2357+ --orig-tarball Write file cloud-init_<version>.orig.tar.gz
2358+ --long Use git describe --long for versioning
2359 EOF
2360 }
2361
2362 short_opts="ho:v"
2363-long_opts="help,output:,long,verbose"
2364+long_opts="help,output:,orig-tarball,long"
2365 getopt_out=$(getopt --name "${0##*/}" \
2366 --options "${short_opts}" --long "${long_opts}" -- "$@") &&
2367 eval set -- "${getopt_out}" || { Usage 1>&2; exit 1; }
2368
2369 long_opt=""
2370+orig_opt=""
2371 while [ $# -ne 0 ]; do
2372 cur=$1; next=$2
2373 case "$cur" in
2374+ -h|--help) Usage; exit 0;;
2375 -o|--output) output=$next; shift;;
2376 --long) long_opt="--long";;
2377+ --orig-tarball) orig_opt=".orig";;
2378 --) shift; break;;
2379 esac
2380 shift;
2381@@ -39,7 +45,10 @@ version=$(git describe --abbrev=8 "--match=[0-9]*" ${long_opt} $rev)
2382
2383 archive_base="cloud-init-$version"
2384 if [ -z "$output" ]; then
2385- output="$archive_base.tar.gz"
2386+ if [ ! -z "$orig_opt" ]; then
2387+ archive_base="cloud-init_$version"
2388+ fi
2389+ output="$archive_base$orig_opt.tar.gz"
2390 fi
2391
2392 # when building an archiving from HEAD, ensure that there aren't any

Subscribers

People subscribed via source and target branches