1"""
2The ``file_tree`` external pillar allows values from all files in a directory
3tree to be imported as Pillar data.
4
5.. note::
6
7    This is an external pillar and is subject to the :ref:`rules and
8    constraints <external-pillars>` governing external pillars.
9
10.. versionadded:: 2015.5.0
11
12In this pillar, data is organized by either Minion ID or Nodegroup name.  To
13setup pillar data for a specific Minion, place it in
14``<root_dir>/hosts/<minion_id>``.  To setup pillar data for an entire
15Nodegroup, place it in ``<root_dir>/nodegroups/<node_group>`` where
16``<node_group>`` is the Nodegroup's name.
17
18Example ``file_tree`` Pillar
19============================
20
21Master Configuration
22--------------------
23
24.. code-block:: yaml
25
26    ext_pillar:
27      - file_tree:
28          root_dir: /srv/ext_pillar
29          follow_dir_links: False
30          keep_newline: True
31
32The ``root_dir`` parameter is required and points to the directory where files
33for each host are stored. The ``follow_dir_links`` parameter is optional and
34defaults to False. If ``follow_dir_links`` is set to True, this external pillar
35will follow symbolic links to other directories.
36
37.. warning::
38    Be careful when using ``follow_dir_links``, as a recursive symlink chain
39    will result in unexpected results.
40
41.. versionchanged:: 2018.3.0
42    If ``root_dir`` is a relative path, it will be treated as relative to the
43    :conf_master:`pillar_roots` of the environment specified by
44    :conf_minion:`pillarenv`. If an environment specifies multiple
45    roots, this module will search for files relative to all of them, in order,
46    merging the results.
47
48If ``keep_newline`` is set to ``True``, then the pillar values for files ending
49in newlines will keep that newline. The default behavior is to remove the
50end-of-file newline. ``keep_newline`` should be turned on if the pillar data is
51intended to be used to deploy a file using ``contents_pillar`` with a
52:py:func:`file.managed <salt.states.file.managed>` state.
53
54.. versionchanged:: 2015.8.4
55    The ``raw_data`` parameter has been renamed to ``keep_newline``. In earlier
56    releases, ``raw_data`` must be used. Also, this parameter can now be a list
57    of globs, allowing for more granular control over which pillar values keep
58    their end-of-file newline. The globs match paths relative to the
59    directories named for minion IDs and nodegroups underneath the ``root_dir``
60    (see the layout examples in the below sections).
61
62    .. code-block:: yaml
63
64        ext_pillar:
65          - file_tree:
66              root_dir: /path/to/root/directory
67              keep_newline:
68                - files/testdir/*
69
70.. note::
71    In earlier releases, this documentation incorrectly stated that binary
72    files would not affected by the ``keep_newline`` configuration.  However,
73    this module does not actually distinguish between binary and text files.
74
75.. versionchanged:: 2017.7.0
76    Templating/rendering has been added. You can now specify a default render
77    pipeline and a black- and whitelist of (dis)allowed renderers.
78
79    ``template`` must be set to ``True`` for templating to happen.
80
81    .. code-block:: yaml
82
83        ext_pillar:
84          - file_tree:
85            root_dir: /path/to/root/directory
86            render_default: jinja|yaml
87            renderer_blacklist:
88              - gpg
89            renderer_whitelist:
90              - jinja
91              - yaml
92            template: True
93
94Assigning Pillar Data to Individual Hosts
95-----------------------------------------
96
97To configure pillar data for each host, this external pillar will recursively
98iterate over ``root_dir``/hosts/``id`` (where ``id`` is a minion ID), and
99compile pillar data with each subdirectory as a dictionary key and each file
100as a value.
101
102For example, the following ``root_dir`` tree:
103
104.. code-block:: text
105
106    ./hosts/
107    ./hosts/test-host/
108    ./hosts/test-host/files/
109    ./hosts/test-host/files/testdir/
110    ./hosts/test-host/files/testdir/file1.txt
111    ./hosts/test-host/files/testdir/file2.txt
112    ./hosts/test-host/files/another-testdir/
113    ./hosts/test-host/files/another-testdir/symlink-to-file1.txt
114
115will result in the following pillar tree for minion with ID ``test-host``:
116
117.. code-block:: text
118
119    test-host:
120        ----------
121        apache:
122            ----------
123            config.d:
124                ----------
125                00_important.conf:
126                    <important_config important_setting="yes" />
127                20_bob_extra.conf:
128                    <bob_specific_cfg has_freeze_ray="yes" />
129        corporate_app:
130            ----------
131            settings:
132                ----------
133                common_settings:
134                    // This is the main settings file for the corporate
135                    // internal web app
136                    main_setting: probably
137                bob_settings:
138                    role: bob
139
140.. note::
141
142    The leaf data in the example shown is the contents of the pillar files.
143"""
144
145import fnmatch
146import logging
147import os
148
149import salt.loader
150import salt.template
151import salt.utils.dictupdate
152import salt.utils.files
153import salt.utils.minions
154import salt.utils.path
155import salt.utils.stringio
156import salt.utils.stringutils
157
158# Set up logging
159log = logging.getLogger(__name__)
160
161
162def _on_walk_error(err):
163    """
164    Log salt.utils.path.os_walk() error.
165    """
166    log.error("%s: %s", err.filename, err.strerror)
167
168
169def _check_newline(prefix, file_name, keep_newline):
170    """
171    Return a boolean stating whether or not a file's trailing newline should be
172    removed. To figure this out, first check if keep_newline is a boolean and
173    if so, return its opposite. Otherwise, iterate over keep_newline and check
174    if any of the patterns match the file path. If a match is found, return
175    False, otherwise return True.
176    """
177    if isinstance(keep_newline, bool):
178        return not keep_newline
179    full_path = os.path.join(prefix, file_name)
180    for pattern in keep_newline:
181        try:
182            if fnmatch.fnmatch(full_path, pattern):
183                return False
184        except TypeError:
185            if fnmatch.fnmatch(full_path, str(pattern)):
186                return False
187    return True
188
189
190def _construct_pillar(
191    top_dir,
192    follow_dir_links,
193    keep_newline=False,
194    render_default=None,
195    renderer_blacklist=None,
196    renderer_whitelist=None,
197    template=False,
198):
199    """
200    Construct pillar from file tree.
201    """
202    pillar = {}
203    renderers = salt.loader.render(__opts__, __salt__)
204
205    norm_top_dir = os.path.normpath(top_dir)
206    for dir_path, dir_names, file_names in salt.utils.path.os_walk(
207        top_dir, topdown=True, onerror=_on_walk_error, followlinks=follow_dir_links
208    ):
209        # Find current path in pillar tree
210        pillar_node = pillar
211        norm_dir_path = os.path.normpath(dir_path)
212        prefix = os.path.relpath(norm_dir_path, norm_top_dir)
213        if norm_dir_path != norm_top_dir:
214            path_parts = []
215            head = prefix
216            while head:
217                head, tail = os.path.split(head)
218                path_parts.insert(0, tail)
219            while path_parts:
220                pillar_node = pillar_node[path_parts.pop(0)]
221
222        # Create dicts for subdirectories
223        for dir_name in dir_names:
224            pillar_node[dir_name] = {}
225
226        # Add files
227        for file_name in file_names:
228            file_path = os.path.join(dir_path, file_name)
229            if not os.path.isfile(file_path):
230                log.error("file_tree: %s: not a regular file", file_path)
231                continue
232
233            contents = b""
234            try:
235                with salt.utils.files.fopen(file_path, "rb") as fhr:
236                    buf = fhr.read(__opts__["file_buffer_size"])
237                    while buf:
238                        contents += buf
239                        buf = fhr.read(__opts__["file_buffer_size"])
240                    if contents.endswith(b"\n") and _check_newline(
241                        prefix, file_name, keep_newline
242                    ):
243                        contents = contents[:-1]
244            except OSError as exc:
245                log.error("file_tree: Error reading %s: %s", file_path, exc.strerror)
246            else:
247                data = contents
248                if template is True:
249                    data = salt.template.compile_template_str(
250                        template=salt.utils.stringutils.to_unicode(contents),
251                        renderers=renderers,
252                        default=render_default,
253                        blacklist=renderer_blacklist,
254                        whitelist=renderer_whitelist,
255                    )
256                if salt.utils.stringio.is_readable(data):
257                    pillar_node[file_name] = data.getvalue()
258                else:
259                    pillar_node[file_name] = data
260
261    return pillar
262
263
264def ext_pillar(
265    minion_id,
266    pillar,
267    root_dir=None,
268    follow_dir_links=False,
269    debug=False,
270    keep_newline=False,
271    render_default=None,
272    renderer_blacklist=None,
273    renderer_whitelist=None,
274    template=False,
275):
276    """
277    Compile pillar data from the given ``root_dir`` specific to Nodegroup names
278    and Minion IDs.
279
280    If a Minion's ID is not found at ``<root_dir>/host/<minion_id>`` or if it
281    is not included in any Nodegroups named at
282    ``<root_dir>/nodegroups/<node_group>``, no pillar data provided by this
283    pillar module will be available for that Minion.
284
285    .. versionchanged:: 2017.7.0
286        Templating/rendering has been added. You can now specify a default
287        render pipeline and a black- and whitelist of (dis)allowed renderers.
288
289        ``template`` must be set to ``True`` for templating to happen.
290
291        .. code-block:: yaml
292
293            ext_pillar:
294              - file_tree:
295                root_dir: /path/to/root/directory
296                render_default: jinja|yaml
297                renderer_blacklist:
298                  - gpg
299                renderer_whitelist:
300                  - jinja
301                  - yaml
302                template: True
303
304    :param minion_id:
305        The ID of the Minion whose pillar data is to be collected
306
307    :param pillar:
308        Unused by the ``file_tree`` pillar module
309
310    :param root_dir:
311        Filesystem directory used as the root for pillar data (e.g.
312        ``/srv/ext_pillar``)
313
314        .. versionchanged:: 2018.3.0
315            If ``root_dir`` is a relative path, it will be treated as relative to the
316            :conf_master:`pillar_roots` of the environment specified by
317            :conf_minion:`pillarenv`. If an environment specifies multiple
318            roots, this module will search for files relative to all of them, in order,
319            merging the results.
320
321    :param follow_dir_links:
322        Follow symbolic links to directories while collecting pillar files.
323        Defaults to ``False``.
324
325        .. warning::
326
327            Care should be exercised when enabling this option as it will
328            follow links that point outside of ``root_dir``.
329
330        .. warning::
331
332            Symbolic links that lead to infinite recursion are not filtered.
333
334    :param debug:
335        Enable debug information at log level ``debug``.  Defaults to
336        ``False``.  This option may be useful to help debug errors when setting
337        up the ``file_tree`` pillar module.
338
339    :param keep_newline:
340        Preserve the end-of-file newline in files.  Defaults to ``False``.
341        This option may either be a boolean or a list of file globs (as defined
342        by the `Python fnmatch package
343        <https://docs.python.org/library/fnmatch.html>`_) for which end-of-file
344        newlines are to be kept.
345
346        ``keep_newline`` should be turned on if the pillar data is intended to
347        be used to deploy a file using ``contents_pillar`` with a
348        :py:func:`file.managed <salt.states.file.managed>` state.
349
350        .. versionchanged:: 2015.8.4
351            The ``raw_data`` parameter has been renamed to ``keep_newline``. In
352            earlier releases, ``raw_data`` must be used. Also, this parameter
353            can now be a list of globs, allowing for more granular control over
354            which pillar values keep their end-of-file newline. The globs match
355            paths relative to the directories named for Minion IDs and
356            Nodegroup namess underneath the ``root_dir``.
357
358            .. code-block:: yaml
359
360                ext_pillar:
361                  - file_tree:
362                      root_dir: /srv/ext_pillar
363                      keep_newline:
364                        - apache/config.d/*
365                        - corporate_app/settings/*
366
367        .. note::
368            In earlier releases, this documentation incorrectly stated that
369            binary files would not affected by the ``keep_newline``.  However,
370            this module does not actually distinguish between binary and text
371            files.
372
373
374    :param render_default:
375        Override Salt's :conf_master:`default global renderer <renderer>` for
376        the ``file_tree`` pillar.
377
378        .. code-block:: yaml
379
380            render_default: jinja
381
382    :param renderer_blacklist:
383        Disallow renderers for pillar files.
384
385        .. code-block:: yaml
386
387            renderer_blacklist:
388              - json
389
390    :param renderer_whitelist:
391        Allow renderers for pillar files.
392
393        .. code-block:: yaml
394
395            renderer_whitelist:
396              - yaml
397              - jinja
398
399    :param template:
400        Enable templating of pillar files.  Defaults to ``False``.
401    """
402    # Not used
403    del pillar
404
405    if not root_dir:
406        log.error("file_tree: no root_dir specified")
407        return {}
408
409    if not os.path.isabs(root_dir):
410        pillarenv = __opts__["pillarenv"]
411        if pillarenv is None:
412            log.error("file_tree: root_dir is relative but pillarenv is not set")
413            return {}
414        log.debug("file_tree: pillarenv = %s", pillarenv)
415
416        env_roots = __opts__["pillar_roots"].get(pillarenv, None)
417        if env_roots is None:
418            log.error(
419                "file_tree: root_dir is relative but no pillar_roots are specified "
420                " for pillarenv %s",
421                pillarenv,
422            )
423            return {}
424
425        env_dirs = []
426        for env_root in env_roots:
427            env_dir = os.path.normpath(os.path.join(env_root, root_dir))
428            # don't redundantly load consecutively, but preserve any expected precedence
429            if env_dir not in env_dirs or env_dir != env_dirs[-1]:
430                env_dirs.append(env_dir)
431        dirs = env_dirs
432    else:
433        dirs = [root_dir]
434
435    result_pillar = {}
436    for root in dirs:
437        dir_pillar = _ext_pillar(
438            minion_id,
439            root,
440            follow_dir_links,
441            debug,
442            keep_newline,
443            render_default,
444            renderer_blacklist,
445            renderer_whitelist,
446            template,
447        )
448        result_pillar = salt.utils.dictupdate.merge(
449            result_pillar, dir_pillar, strategy="recurse"
450        )
451    return result_pillar
452
453
454def _ext_pillar(
455    minion_id,
456    root_dir,
457    follow_dir_links,
458    debug,
459    keep_newline,
460    render_default,
461    renderer_blacklist,
462    renderer_whitelist,
463    template,
464):
465    """
466    Compile pillar data for a single root_dir for the specified minion ID
467    """
468    log.debug("file_tree: reading %s", root_dir)
469
470    if not os.path.isdir(root_dir):
471        log.error(
472            "file_tree: root_dir %s does not exist or is not a directory", root_dir
473        )
474        return {}
475
476    if not isinstance(keep_newline, (bool, list)):
477        log.error(
478            "file_tree: keep_newline must be either True/False or a list "
479            "of file globs. Skipping this ext_pillar for root_dir %s",
480            root_dir,
481        )
482        return {}
483
484    ngroup_pillar = {}
485    nodegroups_dir = os.path.join(root_dir, "nodegroups")
486    if os.path.exists(nodegroups_dir) and len(__opts__.get("nodegroups", ())) > 0:
487        master_ngroups = __opts__["nodegroups"]
488        ext_pillar_dirs = os.listdir(nodegroups_dir)
489        if len(ext_pillar_dirs) > 0:
490            for nodegroup in ext_pillar_dirs:
491                if os.path.isdir(nodegroups_dir) and nodegroup in master_ngroups:
492                    ckminions = salt.utils.minions.CkMinions(__opts__)
493                    _res = ckminions.check_minions(
494                        master_ngroups[nodegroup], "compound"
495                    )
496                    match = _res["minions"]
497                    if minion_id in match:
498                        ngroup_dir = os.path.join(nodegroups_dir, str(nodegroup))
499                        ngroup_pillar = salt.utils.dictupdate.merge(
500                            ngroup_pillar,
501                            _construct_pillar(
502                                ngroup_dir,
503                                follow_dir_links,
504                                keep_newline,
505                                render_default,
506                                renderer_blacklist,
507                                renderer_whitelist,
508                                template,
509                            ),
510                            strategy="recurse",
511                        )
512        else:
513            if debug is True:
514                log.debug(
515                    "file_tree: no nodegroups found in file tree directory %s,"
516                    " skipping...",
517                    ext_pillar_dirs,
518                )
519    else:
520        if debug is True:
521            log.debug("file_tree: no nodegroups found in master configuration")
522
523    host_dir = os.path.join(root_dir, "hosts", minion_id)
524    if not os.path.exists(host_dir):
525        if debug is True:
526            log.debug(
527                "file_tree: no pillar data for minion %s found in file tree"
528                " directory %s",
529                minion_id,
530                host_dir,
531            )
532        return ngroup_pillar
533
534    if not os.path.isdir(host_dir):
535        log.error("file_tree: %s exists, but is not a directory", host_dir)
536        return ngroup_pillar
537
538    host_pillar = _construct_pillar(
539        host_dir,
540        follow_dir_links,
541        keep_newline,
542        render_default,
543        renderer_blacklist,
544        renderer_whitelist,
545        template,
546    )
547    return salt.utils.dictupdate.merge(ngroup_pillar, host_pillar, strategy="recurse")
548