1# Copyright (C) 2016 Canonical Ltd.
2#
3# Author: Ryan Harper <ryan.harper@canonical.com>
4#
5# This file is part of cloud-init. See LICENSE file for license information.
6
7"""NTP: enable and configure ntp"""
8
9import copy
10import os
11from textwrap import dedent
12
13from cloudinit import log as logging
14from cloudinit import temp_utils
15from cloudinit import templater
16from cloudinit import type_utils
17from cloudinit import subp
18from cloudinit import util
19from cloudinit.config.schema import get_schema_doc, validate_cloudconfig_schema
20from cloudinit.settings import PER_INSTANCE
21
22LOG = logging.getLogger(__name__)
23
24frequency = PER_INSTANCE
25NTP_CONF = '/etc/ntp.conf'
26NR_POOL_SERVERS = 4
27distros = ['almalinux', 'alpine', 'centos', 'cloudlinux', 'debian',
28           'eurolinux', 'fedora', 'openEuler', 'opensuse', 'photon',
29           'rhel', 'rocky', 'sles', 'ubuntu', 'virtuozzo']
30
31NTP_CLIENT_CONFIG = {
32    'chrony': {
33        'check_exe': 'chronyd',
34        'confpath': '/etc/chrony.conf',
35        'packages': ['chrony'],
36        'service_name': 'chrony',
37        'template_name': 'chrony.conf.{distro}',
38        'template': None,
39    },
40    'ntp': {
41        'check_exe': 'ntpd',
42        'confpath': NTP_CONF,
43        'packages': ['ntp'],
44        'service_name': 'ntp',
45        'template_name': 'ntp.conf.{distro}',
46        'template': None,
47    },
48    'ntpdate': {
49        'check_exe': 'ntpdate',
50        'confpath': NTP_CONF,
51        'packages': ['ntpdate'],
52        'service_name': 'ntpdate',
53        'template_name': 'ntp.conf.{distro}',
54        'template': None,
55    },
56    'systemd-timesyncd': {
57        'check_exe': '/lib/systemd/systemd-timesyncd',
58        'confpath': '/etc/systemd/timesyncd.conf.d/cloud-init.conf',
59        'packages': [],
60        'service_name': 'systemd-timesyncd',
61        'template_name': 'timesyncd.conf',
62        'template': None,
63    },
64}
65
66# This is Distro-specific configuration overrides of the base config
67DISTRO_CLIENT_CONFIG = {
68    'alpine': {
69        'chrony': {
70            'confpath': '/etc/chrony/chrony.conf',
71            'service_name': 'chronyd',
72        },
73        'ntp': {
74            'confpath': '/etc/ntp.conf',
75            'packages': [],
76            'service_name': 'ntpd',
77        },
78    },
79    'debian': {
80        'chrony': {
81            'confpath': '/etc/chrony/chrony.conf',
82        },
83    },
84    'opensuse': {
85        'chrony': {
86            'service_name': 'chronyd',
87        },
88        'ntp': {
89            'confpath': '/etc/ntp.conf',
90            'service_name': 'ntpd',
91        },
92        'systemd-timesyncd': {
93            'check_exe': '/usr/lib/systemd/systemd-timesyncd',
94        },
95    },
96    'photon': {
97        'chrony': {
98            'service_name': 'chronyd',
99        },
100        'ntp': {
101            'service_name': 'ntpd',
102            'confpath': '/etc/ntp.conf'
103        },
104        'systemd-timesyncd': {
105            'check_exe': '/usr/lib/systemd/systemd-timesyncd',
106            'confpath': '/etc/systemd/timesyncd.conf',
107        },
108    },
109    'rhel': {
110        'ntp': {
111            'service_name': 'ntpd',
112        },
113        'chrony': {
114            'service_name': 'chronyd',
115        },
116    },
117    'sles': {
118        'chrony': {
119            'service_name': 'chronyd',
120        },
121        'ntp': {
122            'confpath': '/etc/ntp.conf',
123            'service_name': 'ntpd',
124        },
125        'systemd-timesyncd': {
126            'check_exe': '/usr/lib/systemd/systemd-timesyncd',
127        },
128    },
129    'ubuntu': {
130        'chrony': {
131            'confpath': '/etc/chrony/chrony.conf',
132        },
133    },
134}
135
136
137# The schema definition for each cloud-config module is a strict contract for
138# describing supported configuration parameters for each cloud-config section.
139# It allows cloud-config to validate and alert users to invalid or ignored
140# configuration options before actually attempting to deploy with said
141# configuration.
142
143schema = {
144    'id': 'cc_ntp',
145    'name': 'NTP',
146    'title': 'enable and configure ntp',
147    'description': dedent("""\
148        Handle ntp configuration. If ntp is not installed on the system and
149        ntp configuration is specified, ntp will be installed. If there is a
150        default ntp config file in the image or one is present in the
151        distro's ntp package, it will be copied to a file with ``.dist``
152        appended to the filename before any changes are made. A list of ntp
153        pools and ntp servers can be provided under the ``ntp`` config key.
154        If no ntp ``servers`` or ``pools`` are provided, 4 pools will be used
155        in the format ``{0-3}.{distro}.pool.ntp.org``."""),
156    'distros': distros,
157    'examples': [
158        dedent("""\
159        # Override ntp with chrony configuration on Ubuntu
160        ntp:
161          enabled: true
162          ntp_client: chrony  # Uses cloud-init default chrony configuration
163        """),
164        dedent("""\
165        # Provide a custom ntp client configuration
166        ntp:
167          enabled: true
168          ntp_client: myntpclient
169          config:
170             confpath: /etc/myntpclient/myntpclient.conf
171             check_exe: myntpclientd
172             packages:
173               - myntpclient
174             service_name: myntpclient
175             template: |
176                 ## template:jinja
177                 # My NTP Client config
178                 {% if pools -%}# pools{% endif %}
179                 {% for pool in pools -%}
180                 pool {{pool}} iburst
181                 {% endfor %}
182                 {%- if servers %}# servers
183                 {% endif %}
184                 {% for server in servers -%}
185                 server {{server}} iburst
186                 {% endfor %}
187          pools: [0.int.pool.ntp.org, 1.int.pool.ntp.org, ntp.myorg.org]
188          servers:
189            - ntp.server.local
190            - ntp.ubuntu.com
191            - 192.168.23.2""")],
192    'frequency': PER_INSTANCE,
193    'type': 'object',
194    'properties': {
195        'ntp': {
196            'type': ['object', 'null'],
197            'properties': {
198                'pools': {
199                    'type': 'array',
200                    'items': {
201                        'type': 'string',
202                        'format': 'hostname'
203                    },
204                    'uniqueItems': True,
205                    'description': dedent("""\
206                        List of ntp pools. If both pools and servers are
207                        empty, 4 default pool servers will be provided of
208                        the format ``{0-3}.{distro}.pool.ntp.org``. NOTE:
209                        for Alpine Linux when using the Busybox NTP client
210                        this setting will be ignored due to the limited
211                        functionality of Busybox's ntpd.""")
212                },
213                'servers': {
214                    'type': 'array',
215                    'items': {
216                        'type': 'string',
217                        'format': 'hostname'
218                    },
219                    'uniqueItems': True,
220                    'description': dedent("""\
221                        List of ntp servers. If both pools and servers are
222                        empty, 4 default pool servers will be provided with
223                        the format ``{0-3}.{distro}.pool.ntp.org``.""")
224                },
225                'ntp_client': {
226                    'type': 'string',
227                    'default': 'auto',
228                    'description': dedent("""\
229                        Name of an NTP client to use to configure system NTP.
230                        When unprovided or 'auto' the default client preferred
231                        by the distribution will be used. The following
232                        built-in client names can be used to override existing
233                        configuration defaults: chrony, ntp, ntpdate,
234                        systemd-timesyncd."""),
235                },
236                'enabled': {
237                    'type': 'boolean',
238                    'default': True,
239                    'description': dedent("""\
240                        Attempt to enable ntp clients if set to True.  If set
241                        to False, ntp client will not be configured or
242                        installed"""),
243                },
244                'config': {
245                    'description': dedent("""\
246                        Configuration settings or overrides for the
247                        ``ntp_client`` specified."""),
248                    'type': ['object'],
249                    'properties': {
250                        'confpath': {
251                            'type': 'string',
252                            'description': dedent("""\
253                                The path to where the ``ntp_client``
254                                configuration is written."""),
255                        },
256                        'check_exe': {
257                            'type': 'string',
258                            'description': dedent("""\
259                                The executable name for the ``ntp_client``.
260                                For example, ntp service ``check_exe`` is
261                                'ntpd' because it runs the ntpd binary."""),
262                        },
263                        'packages': {
264                            'type': 'array',
265                            'items': {
266                                'type': 'string',
267                            },
268                            'uniqueItems': True,
269                            'description': dedent("""\
270                                List of packages needed to be installed for the
271                                selected ``ntp_client``."""),
272                        },
273                        'service_name': {
274                            'type': 'string',
275                            'description': dedent("""\
276                                The systemd or sysvinit service name used to
277                                start and stop the ``ntp_client``
278                                service."""),
279                        },
280                        'template': {
281                            'type': 'string',
282                            'description': dedent("""\
283                                Inline template allowing users to define their
284                                own ``ntp_client`` configuration template.
285                                The value must start with '## template:jinja'
286                                to enable use of templating support.
287                                """),
288                        },
289                    },
290                    # Don't use REQUIRED_NTP_CONFIG_KEYS to allow for override
291                    # of builtin client values.
292                    'required': [],
293                    'minProperties': 1,  # If we have config, define something
294                    'additionalProperties': False
295                },
296            },
297            'required': [],
298            'additionalProperties': False
299        }
300    }
301}
302REQUIRED_NTP_CONFIG_KEYS = frozenset([
303    'check_exe', 'confpath', 'packages', 'service_name'])
304
305
306__doc__ = get_schema_doc(schema)  # Supplement python help()
307
308
309def distro_ntp_client_configs(distro):
310    """Construct a distro-specific ntp client config dictionary by merging
311       distro specific changes into base config.
312
313    @param distro: String providing the distro class name.
314    @returns: Dict of distro configurations for ntp clients.
315    """
316    dcfg = DISTRO_CLIENT_CONFIG
317    cfg = copy.copy(NTP_CLIENT_CONFIG)
318    if distro in dcfg:
319        cfg = util.mergemanydict([cfg, dcfg[distro]], reverse=True)
320    return cfg
321
322
323def select_ntp_client(ntp_client, distro):
324    """Determine which ntp client is to be used, consulting the distro
325       for its preference.
326
327    @param ntp_client: String name of the ntp client to use.
328    @param distro: Distro class instance.
329    @returns: Dict of the selected ntp client or {} if none selected.
330    """
331
332    # construct distro-specific ntp_client_config dict
333    distro_cfg = distro_ntp_client_configs(distro.name)
334
335    # user specified client, return its config
336    if ntp_client and ntp_client != 'auto':
337        LOG.debug('Selected NTP client "%s" via user-data configuration',
338                  ntp_client)
339        return distro_cfg.get(ntp_client, {})
340
341    # default to auto if unset in distro
342    distro_ntp_client = distro.get_option('ntp_client', 'auto')
343
344    clientcfg = {}
345    if distro_ntp_client == "auto":
346        for client in distro.preferred_ntp_clients:
347            cfg = distro_cfg.get(client)
348            if subp.which(cfg.get('check_exe')):
349                LOG.debug('Selected NTP client "%s", already installed',
350                          client)
351                clientcfg = cfg
352                break
353
354        if not clientcfg:
355            client = distro.preferred_ntp_clients[0]
356            LOG.debug(
357                'Selected distro preferred NTP client "%s", not yet installed',
358                client)
359            clientcfg = distro_cfg.get(client)
360    else:
361        LOG.debug('Selected NTP client "%s" via distro system config',
362                  distro_ntp_client)
363        clientcfg = distro_cfg.get(distro_ntp_client, {})
364
365    return clientcfg
366
367
368def install_ntp_client(install_func, packages=None, check_exe="ntpd"):
369    """Install ntp client package if not already installed.
370
371    @param install_func: function.  This parameter is invoked with the contents
372    of the packages parameter.
373    @param packages: list.  This parameter defaults to ['ntp'].
374    @param check_exe: string.  The name of a binary that indicates the package
375    the specified package is already installed.
376    """
377    if subp.which(check_exe):
378        return
379    if packages is None:
380        packages = ['ntp']
381
382    install_func(packages)
383
384
385def rename_ntp_conf(confpath=None):
386    """Rename any existing ntp client config file
387
388    @param confpath: string. Specify a path to an existing ntp client
389    configuration file.
390    """
391    if os.path.exists(confpath):
392        util.rename(confpath, confpath + ".dist")
393
394
395def generate_server_names(distro):
396    """Generate a list of server names to populate an ntp client configuration
397    file.
398
399    @param distro: string.  Specify the distro name
400    @returns: list: A list of strings representing ntp servers for this distro.
401    """
402    names = []
403    pool_distro = distro
404
405    if distro == 'sles':
406        # For legal reasons x.pool.sles.ntp.org does not exist,
407        # use the opensuse pool
408        pool_distro = 'opensuse'
409    elif distro == 'alpine' or distro == 'eurolinux':
410        # Alpine-specific pool (i.e. x.alpine.pool.ntp.org) does not exist
411        # so use general x.pool.ntp.org instead. The same applies to EuroLinux
412        pool_distro = ''
413
414    for x in range(0, NR_POOL_SERVERS):
415        names.append(".".join(
416            [n for n in [str(x)] + [pool_distro] + ['pool.ntp.org'] if n]))
417
418    return names
419
420
421def write_ntp_config_template(distro_name, service_name=None, servers=None,
422                              pools=None, path=None, template_fn=None,
423                              template=None):
424    """Render a ntp client configuration for the specified client.
425
426    @param distro_name: string.  The distro class name.
427    @param service_name: string. The name of the NTP client service.
428    @param servers: A list of strings specifying ntp servers. Defaults to empty
429    list.
430    @param pools: A list of strings specifying ntp pools. Defaults to empty
431    list.
432    @param path: A string to specify where to write the rendered template.
433    @param template_fn: A string to specify the template source file.
434    @param template: A string specifying the contents of the template. This
435    content will be written to a temporary file before being used to render
436    the configuration file.
437
438    @raises: ValueError when path is None.
439    @raises: ValueError when template_fn is None and template is None.
440    """
441    if not servers:
442        servers = []
443    if not pools:
444        pools = []
445
446    if (len(servers) == 0 and distro_name == 'alpine' and
447            service_name == 'ntpd'):
448        # Alpine's Busybox ntpd only understands "servers" configuration
449        # and not "pool" configuration.
450        servers = generate_server_names(distro_name)
451        LOG.debug(
452            'Adding distro default ntp servers: %s', ','.join(servers))
453    elif len(servers) == 0 and len(pools) == 0:
454        pools = generate_server_names(distro_name)
455        LOG.debug(
456            'Adding distro default ntp pool servers: %s', ','.join(pools))
457
458    if not path:
459        raise ValueError('Invalid value for path parameter')
460
461    if not template_fn and not template:
462        raise ValueError('Not template_fn or template provided')
463
464    params = {'servers': servers, 'pools': pools}
465    if template:
466        tfile = temp_utils.mkstemp(prefix='template_name-', suffix=".tmpl")
467        template_fn = tfile[1]  # filepath is second item in tuple
468        util.write_file(template_fn, content=template)
469
470    templater.render_to_file(template_fn, path, params)
471    # clean up temporary template
472    if template:
473        util.del_file(template_fn)
474
475
476def supplemental_schema_validation(ntp_config):
477    """Validate user-provided ntp:config option values.
478
479    This function supplements flexible jsonschema validation with specific
480    value checks to aid in triage of invalid user-provided configuration.
481
482    @param ntp_config: Dictionary of configuration value under 'ntp'.
483
484    @raises: ValueError describing invalid values provided.
485    """
486    errors = []
487    missing = REQUIRED_NTP_CONFIG_KEYS.difference(set(ntp_config.keys()))
488    if missing:
489        keys = ', '.join(sorted(missing))
490        errors.append(
491            'Missing required ntp:config keys: {keys}'.format(keys=keys))
492    elif not any([ntp_config.get('template'),
493                  ntp_config.get('template_name')]):
494        errors.append(
495            'Either ntp:config:template or ntp:config:template_name values'
496            ' are required')
497    for key, value in sorted(ntp_config.items()):
498        keypath = 'ntp:config:' + key
499        if key == 'confpath':
500            if not all([value, isinstance(value, str)]):
501                errors.append(
502                    'Expected a config file path {keypath}.'
503                    ' Found ({value})'.format(keypath=keypath, value=value))
504        elif key == 'packages':
505            if not isinstance(value, list):
506                errors.append(
507                    'Expected a list of required package names for {keypath}.'
508                    ' Found ({value})'.format(keypath=keypath, value=value))
509        elif key in ('template', 'template_name'):
510            if value is None:  # Either template or template_name can be none
511                continue
512            if not isinstance(value, str):
513                errors.append(
514                    'Expected a string type for {keypath}.'
515                    ' Found ({value})'.format(keypath=keypath, value=value))
516        elif not isinstance(value, str):
517            errors.append(
518                'Expected a string type for {keypath}.'
519                ' Found ({value})'.format(keypath=keypath, value=value))
520
521    if errors:
522        raise ValueError(r'Invalid ntp configuration:\n{errors}'.format(
523            errors='\n'.join(errors)))
524
525
526def handle(name, cfg, cloud, log, _args):
527    """Enable and configure ntp."""
528    if 'ntp' not in cfg:
529        LOG.debug(
530            "Skipping module named %s, not present or disabled by cfg", name)
531        return
532    ntp_cfg = cfg['ntp']
533    if ntp_cfg is None:
534        ntp_cfg = {}  # Allow empty config which will install the package
535
536    # TODO drop this when validate_cloudconfig_schema is strict=True
537    if not isinstance(ntp_cfg, (dict)):
538        raise RuntimeError(
539            "'ntp' key existed in config, but not a dictionary type,"
540            " is a {_type} instead".format(_type=type_utils.obj_name(ntp_cfg)))
541
542    validate_cloudconfig_schema(cfg, schema)
543
544    # Allow users to explicitly enable/disable
545    enabled = ntp_cfg.get('enabled', True)
546    if util.is_false(enabled):
547        LOG.debug("Skipping module named %s, disabled by cfg", name)
548        return
549
550    # Select which client is going to be used and get the configuration
551    ntp_client_config = select_ntp_client(ntp_cfg.get('ntp_client'),
552                                          cloud.distro)
553    # Allow user ntp config to override distro configurations
554    ntp_client_config = util.mergemanydict(
555        [ntp_client_config, ntp_cfg.get('config', {})], reverse=True)
556
557    supplemental_schema_validation(ntp_client_config)
558    rename_ntp_conf(confpath=ntp_client_config.get('confpath'))
559
560    template_fn = None
561    if not ntp_client_config.get('template'):
562        template_name = (
563            ntp_client_config.get('template_name').replace('{distro}',
564                                                           cloud.distro.name))
565        template_fn = cloud.get_template_filename(template_name)
566        if not template_fn:
567            msg = ('No template found, not rendering %s' %
568                   ntp_client_config.get('template_name'))
569            raise RuntimeError(msg)
570
571    write_ntp_config_template(cloud.distro.name,
572                              service_name=ntp_client_config.get(
573                                  'service_name'),
574                              servers=ntp_cfg.get('servers', []),
575                              pools=ntp_cfg.get('pools', []),
576                              path=ntp_client_config.get('confpath'),
577                              template_fn=template_fn,
578                              template=ntp_client_config.get('template'))
579
580    install_ntp_client(cloud.distro.install_packages,
581                       packages=ntp_client_config['packages'],
582                       check_exe=ntp_client_config['check_exe'])
583    try:
584        cloud.distro.manage_service('reload',
585                                    ntp_client_config.get('service_name'))
586    except subp.ProcessExecutionError as e:
587        LOG.exception("Failed to reload/start ntp service: %s", e)
588        raise
589
590
591# vi: ts=4 expandtab
592