# ============================================================================== # Copyright 2011 Amazon.com, Inc. or its affiliates. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ============================================================================== from cfnbootstrap import security, util from cfnbootstrap.construction_errors import ToolError from contextlib import contextmanager import base64 import logging import os from cfnbootstrap.packages import requests import stat import binascii import json log = logging.getLogger("cfn.init") class FileTool(object): """ Writes files to disk """ _compare_buffer = 8 * 1024 @classmethod def is_same_file(cls, f1, f2): if hasattr(os.path, "samefile"): return os.path.samefile(f1, f2) else: # Crude workaround for os.path.samefile only existing on Unix return os.path.normcase(os.path.abspath(f1)) == os.path.normcase(os.path.abspath(f2)) @classmethod def compare_file_contents(cls, f1, f2): """ Return true if f1 and f2 have the same content. """ if os.path.getsize(f1) != os.path.getsize(f2): return False if cls.is_same_file(f1, f2): return True # Borrowed from filecmp with open(f1, "rb") as fp1: with open(f2, "rb") as fp2: bufsize = 8 * 1024 while True: b1 = fp1.read(bufsize) b2 = fp2.read(bufsize) if b1 != b2: return False if not b1: return True def apply(self, action, auth_config): """ Write a set of files to disk, returning a list of the files that have changed. Arguments: action -- a dict of pathname to attributes, such as owner, group, mode, content, and encoding auth_config -- an AuthenticationConfig object for managing authenticated downloads Exceptions: ToolError -- on expected failures """ files_changed = [] if not action.keys(): log.debug("No files specified") return files_changed for (filename, attribs) in sorted(action.items(), key=lambda pair: pair[0]): if not os.path.isabs(filename): raise ToolError( 'File specified with non-absolute path: %s' % filename) # The only difference between a file and a symlink is hidden in the mode file_is_link = "mode" in attribs and stat.S_ISLNK( int(attribs["mode"], 8)) if file_is_link: if "content" not in attribs: raise ToolError( "Symbolic link specified without a destination") elif os.path.exists(filename) and FileTool.is_same_file(os.path.realpath(filename), attribs["content"]): log.info("Symbolic link %s already exists", filename) continue parent = os.path.dirname(filename) if not os.path.isdir(parent): if not os.path.exists(parent): log.debug( "Parent directory %s does not exist, creating", parent) os.makedirs(parent) else: raise ToolError( "Parent directory %s exists and is a file" % parent) currentFileExists = False # if file exists, copy its current mode if os.path.isfile(filename): currentFileExists = True currentFileMode = stat.S_IMODE(os.stat(filename).st_mode) with self.backup(filename, files_changed): if file_is_link: log.debug("%s is specified as a symbolic link to %s", filename, attribs['content']) os.symlink(attribs["content"], filename) else: file_is_text = 'content' in attribs and not self._is_base64( attribs) log.debug("Writing content to %s", filename) if file_is_text: with open(filename, 'w', encoding='utf-8') as f: self._write_file(f, attribs, auth_config) else: with open(filename, "wb") as f: self._write_file(f, attribs, auth_config) if "mode" in attribs: log.debug("Setting mode for %s to %s", filename, attribs["mode"]) os.chmod(filename, stat.S_IMODE(int(attribs["mode"], 8))) else: if currentFileExists: log.debug("No mode specified for %s. Setting permissions of the existing file: %s.", filename, oct( currentFileMode)) os.chmod(filename, currentFileMode) else: # get attributes that the file was just created currentFileMode = stat.S_IMODE( os.stat(filename).st_mode) # filter the curent mode by giving up others writable currentFileMode = currentFileMode & 0o775 log.debug("No mode specified for %s. The file will be created with the mode: %s", filename, oct( currentFileMode)) os.chmod(filename, currentFileMode) security.set_owner_and_group( filename, attribs.get("owner"), attribs.get("group")) return files_changed @contextmanager def backup(self, filename, files_changed): backup_file = None backup_backup_file = None if os.path.exists(filename): log.debug("%s already exists", filename) backup_file = filename + '.bak' if os.path.exists(backup_file): backup_backup_file = backup_file + "2" if os.path.exists(backup_backup_file): os.remove(backup_backup_file) self._backup_file(backup_file, backup_backup_file) self._backup_file(filename, backup_file) try: yield backup_file except Exception: if backup_file: try: if os.path.exists(filename): os.remove(filename) self._backup_file(backup_file, filename) if backup_backup_file: self._backup_file(backup_backup_file, backup_file) except ToolError as t: log.warn("Error restoring %s from backup", filename) raise else: linkmode = backup_file and os.path.islink( backup_file) or os.path.islink(filename) # we assume any symbolic links changed because we short-circuit links to the same files early on if not backup_file or linkmode or not FileTool.compare_file_contents(backup_file, filename): files_changed.append(filename) if backup_backup_file: os.remove(backup_backup_file) elif backup_file and backup_backup_file: try: self._backup_file(backup_backup_file, backup_file) except ToolError as t: log.warn("Error restoring backup file %s: %s", backup_file, str(t)) def _backup_file(self, source, dest): try: log.debug("Moving %s to %s", source, dest) os.rename(source, dest) except OSError as e: log.error("Could not move %s to %s", source, dest) raise ToolError("Could not rename %s: %s" % (source, str(e))) def _is_base64(self, attribs): return attribs.get("encoding", "plain") == "base64" def _write_file(self, dest_fileobj, attribs, auth_config): content = attribs.get("content", "") if content: self._write_inline_content(dest_fileobj, content, self._is_base64(attribs), attribs.get('context')) else: source = attribs.get("source", "") if not source: raise ToolError("File specified without source or content") log.debug("Retrieving contents from %s", source) try: self._write_remote_file(source, auth_config.get_auth(attribs.get('authentication', None)), dest_fileobj, attribs.get('context')) except IOError as e: raise ToolError("Failed to retrieve %s: %s" % (source, e.strerror)) def _render_template(self, content, context): """Tries rendering template with chevron first. If chevron is not present then uses pystache.""" log.debug('Rendering as Mustache template') try: import chevron if isinstance(content, str): return chevron.render(content, context) else: # handle byte content return chevron.render(content.decode('UTF-8'), context) except ImportError: log.debug("Chevron is not installed trying pystache") except Exception as e: raise ToolError("Failed to render content as a Mustache template: %s" % getattr(e, 'message', repr(e))) try: from pystache.renderer import Renderer return Renderer(string_encoding='utf-8', file_encoding='utf-8').render(content, context) except ImportError: raise ToolError("Failed to render the template since pystache and chevron are missing") except Exception as e: raise ToolError("Failed to render content as a Mustache template: %s" % getattr(e, 'message', repr(e))) @util.retry_on_failure() def _write_remote_file(self, source, auth, dest, context): opts = util.req_opts({'auth': auth}) remote_contents = util.EtagCheckedResponse( requests.get(source, **opts)) if context is None: remote_contents.write_to(dest) else: dest.write(self._render_template( remote_contents.contents(), context).encode()) def _write_inline_content(self, dest, content, is_base64, context): if not isinstance(content, str): log.debug('Content will be serialized as a JSON structure') json.dump(content, dest) return if is_base64: try: log.debug("Decoding base64 content") dest.write(base64.b64decode(content.strip())) except (TypeError, binascii.Error): raise ToolError("Malformed base64: %s" % content) elif context is None: dest.write(content) else: dest.write(self._render_template(content, context))