1# Copyright (C) 2012 Yahoo! Inc.
2#
3# Author: Joshua Harlow <harlowja@yahoo-inc.com>
4#
5# This file is part of cloud-init. See LICENSE file for license information.
6
7"""Write Files: write arbitrary files"""
8
9import base64
10import os
11from textwrap import dedent
12
13from cloudinit.config.schema import (
14    get_schema_doc, validate_cloudconfig_schema)
15from cloudinit import log as logging
16from cloudinit.settings import PER_INSTANCE
17from cloudinit import util
18
19
20frequency = PER_INSTANCE
21
22DEFAULT_OWNER = "root:root"
23DEFAULT_PERMS = 0o644
24DEFAULT_DEFER = False
25UNKNOWN_ENC = 'text/plain'
26
27LOG = logging.getLogger(__name__)
28
29distros = ['all']
30
31# The schema definition for each cloud-config module is a strict contract for
32# describing supported configuration parameters for each cloud-config section.
33# It allows cloud-config to validate and alert users to invalid or ignored
34# configuration options before actually attempting to deploy with said
35# configuration.
36
37supported_encoding_types = [
38    'gz', 'gzip', 'gz+base64', 'gzip+base64', 'gz+b64', 'gzip+b64', 'b64',
39    'base64']
40
41schema = {
42    'id': 'cc_write_files',
43    'name': 'Write Files',
44    'title': 'write arbitrary files',
45    'description': dedent("""\
46        Write out arbitrary content to files, optionally setting permissions.
47        Parent folders in the path are created if absent.
48        Content can be specified in plain text or binary. Data encoded with
49        either base64 or binary gzip data can be specified and will be decoded
50        before being written. For empty file creation, content can be omitted.
51
52    .. note::
53        if multiline data is provided, care should be taken to ensure that it
54        follows yaml formatting standards. to specify binary data, use the yaml
55        option ``!!binary``
56
57    .. note::
58        Do not write files under /tmp during boot because of a race with
59        systemd-tmpfiles-clean that can cause temp files to get cleaned during
60        the early boot process. Use /run/somedir instead to avoid race
61        LP:1707222."""),
62    'distros': distros,
63    'examples': [
64        dedent("""\
65        # Write out base64 encoded content to /etc/sysconfig/selinux
66        write_files:
67        - encoding: b64
68          content: CiMgVGhpcyBmaWxlIGNvbnRyb2xzIHRoZSBzdGF0ZSBvZiBTRUxpbnV4...
69          owner: root:root
70          path: /etc/sysconfig/selinux
71          permissions: '0644'
72        """),
73        dedent("""\
74        # Appending content to an existing file
75        write_files:
76        - content: |
77            15 * * * * root ship_logs
78          path: /etc/crontab
79          append: true
80        """),
81        dedent("""\
82        # Provide gziped binary content
83        write_files:
84        - encoding: gzip
85          content: !!binary |
86              H4sIAIDb/U8C/1NW1E/KzNMvzuBKTc7IV8hIzcnJVyjPL8pJ4QIA6N+MVxsAAAA=
87          path: /usr/bin/hello
88          permissions: '0755'
89        """),
90        dedent("""\
91        # Create an empty file on the system
92        write_files:
93        - path: /root/CLOUD_INIT_WAS_HERE
94        """),
95        dedent("""\
96        # Defer writing the file until after the package (Nginx) is
97        # installed and its user is created alongside
98        write_files:
99        - path: /etc/nginx/conf.d/example.com.conf
100          content: |
101            server {
102                server_name example.com;
103                listen 80;
104                root /var/www;
105                location / {
106                    try_files $uri $uri/ $uri.html =404;
107                }
108            }
109          owner: 'nginx:nginx'
110          permissions: '0640'
111          defer: true
112        """)],
113    'frequency': frequency,
114    'type': 'object',
115    'properties': {
116        'write_files': {
117            'type': 'array',
118            'items': {
119                'type': 'object',
120                'properties': {
121                    'path': {
122                        'type': 'string',
123                        'description': dedent("""\
124                            Path of the file to which ``content`` is decoded
125                            and written
126                        """),
127                    },
128                    'content': {
129                        'type': 'string',
130                        'default': '',
131                        'description': dedent("""\
132                            Optional content to write to the provided ``path``.
133                            When content is present and encoding is not '%s',
134                            decode the content prior to writing. Default:
135                            **''**
136                        """ % UNKNOWN_ENC),
137                    },
138                    'owner': {
139                        'type': 'string',
140                        'default': DEFAULT_OWNER,
141                        'description': dedent("""\
142                            Optional owner:group to chown on the file. Default:
143                            **{owner}**
144                        """.format(owner=DEFAULT_OWNER)),
145                    },
146                    'permissions': {
147                        'type': 'string',
148                        'default': oct(DEFAULT_PERMS).replace('o', ''),
149                        'description': dedent("""\
150                            Optional file permissions to set on ``path``
151                            represented as an octal string '0###'. Default:
152                            **'{perms}'**
153                        """.format(perms=oct(DEFAULT_PERMS).replace('o', ''))),
154                    },
155                    'encoding': {
156                        'type': 'string',
157                        'default': UNKNOWN_ENC,
158                        'enum': supported_encoding_types,
159                        'description': dedent("""\
160                            Optional encoding type of the content. Default is
161                            **text/plain** and no content decoding is
162                            performed. Supported encoding types are:
163                            %s.""" % ", ".join(supported_encoding_types)),
164                    },
165                    'append': {
166                        'type': 'boolean',
167                        'default': False,
168                        'description': dedent("""\
169                            Whether to append ``content`` to existing file if
170                            ``path`` exists. Default: **false**.
171                        """),
172                    },
173                    'defer': {
174                        'type': 'boolean',
175                        'default': DEFAULT_DEFER,
176                        'description': dedent("""\
177                            Defer writing the file until 'final' stage, after
178                            users were created, and packages were installed.
179                            Default: **{defer}**.
180                        """.format(defer=DEFAULT_DEFER)),
181                    },
182                },
183                'required': ['path'],
184                'additionalProperties': False
185            },
186        }
187    }
188}
189
190__doc__ = get_schema_doc(schema)  # Supplement python help()
191
192
193def handle(name, cfg, _cloud, log, _args):
194    validate_cloudconfig_schema(cfg, schema)
195    file_list = cfg.get('write_files', [])
196    filtered_files = [
197        f for f in file_list if not util.get_cfg_option_bool(f,
198                                                             'defer',
199                                                             DEFAULT_DEFER)
200    ]
201    if not filtered_files:
202        log.debug(("Skipping module named %s,"
203                   " no/empty 'write_files' key in configuration"), name)
204        return
205    write_files(name, filtered_files)
206
207
208def canonicalize_extraction(encoding_type):
209    if not encoding_type:
210        encoding_type = ''
211    encoding_type = encoding_type.lower().strip()
212    if encoding_type in ['gz', 'gzip']:
213        return ['application/x-gzip']
214    if encoding_type in ['gz+base64', 'gzip+base64', 'gz+b64', 'gzip+b64']:
215        return ['application/base64', 'application/x-gzip']
216    # Yaml already encodes binary data as base64 if it is given to the
217    # yaml file as binary, so those will be automatically decoded for you.
218    # But the above b64 is just for people that are more 'comfortable'
219    # specifing it manually (which might be a possiblity)
220    if encoding_type in ['b64', 'base64']:
221        return ['application/base64']
222    if encoding_type:
223        LOG.warning("Unknown encoding type %s, assuming %s",
224                    encoding_type, UNKNOWN_ENC)
225    return [UNKNOWN_ENC]
226
227
228def write_files(name, files):
229    if not files:
230        return
231
232    for (i, f_info) in enumerate(files):
233        path = f_info.get('path')
234        if not path:
235            LOG.warning("No path provided to write for entry %s in module %s",
236                        i + 1, name)
237            continue
238        path = os.path.abspath(path)
239        extractions = canonicalize_extraction(f_info.get('encoding'))
240        contents = extract_contents(f_info.get('content', ''), extractions)
241        (u, g) = util.extract_usergroup(f_info.get('owner', DEFAULT_OWNER))
242        perms = decode_perms(f_info.get('permissions'), DEFAULT_PERMS)
243        omode = 'ab' if util.get_cfg_option_bool(f_info, 'append') else 'wb'
244        util.write_file(path, contents, omode=omode, mode=perms)
245        util.chownbyname(path, u, g)
246
247
248def decode_perms(perm, default):
249    if perm is None:
250        return default
251    try:
252        if isinstance(perm, (int, float)):
253            # Just 'downcast' it (if a float)
254            return int(perm)
255        else:
256            # Force to string and try octal conversion
257            return int(str(perm), 8)
258    except (TypeError, ValueError):
259        reps = []
260        for r in (perm, default):
261            try:
262                reps.append("%o" % r)
263            except TypeError:
264                reps.append("%r" % r)
265        LOG.warning(
266            "Undecodable permissions %s, returning default %s", *reps)
267        return default
268
269
270def extract_contents(contents, extraction_types):
271    result = contents
272    for t in extraction_types:
273        if t == 'application/x-gzip':
274            result = util.decomp_gzip(result, quiet=False, decode=False)
275        elif t == 'application/base64':
276            result = base64.b64decode(result)
277        elif t == UNKNOWN_ENC:
278            pass
279    return result
280
281# vi: ts=4 expandtab
282