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