def handle_conf_file(self, args, conf_proxy): if args.conf_type: conf_type = args.conf_type else: conf_type = os.path.basename(conf_proxy.name).replace(".conf", "") if isinstance(conf_type, six.text_type): conf_type = conf_type.encode("utf-8") config_file = self._service.confs[conf_type] conf = conf_proxy.data # Sorting stanza for consistent processing of large files. No CLI option for now. # XXX: Support stanza order preservation after new parser is created (long-term) for stanza_name in sorted(conf): stanza_data = conf[stanza_name] if stanza_name is GLOBAL_STANZA: # XXX: Research proper handling of default/global stanzas.. # As-is, curl returns an HTTP error, but yet the new entry is added to the # conf file. So I suppose we could ignore the exit code?! ¯\_(ツ)_/¯ sys.stderr.write("Refusing to touch the [default] stanza. Too much could go wrong.\n") continue if args.delete: action, info = self.delete_conf(stanza_name, stanza_data, config_file) else: action, info = self.publish_conf(stanza_name, stanza_data, config_file) print("{:50} {:8} (delta size: {})".format("[{}]".format(stanza_name), action, len(info.get("delta",[])))) update_time = info.get("updated", 0) ###headers = (conf_proxy.name, "{}/{}".format(args.url, config_file.path)) #rest_header = DiffHeader("{}/{}".format(args.url, info.get("path", config_file.path), update_time)) rest_header = DiffHeader(info.get("path", config_file.path), update_time) if action != "nochange" and "delta" in info: show_diff(self.stdout, info["delta"], headers=(conf_proxy.name, rest_header)) if "meta" in info: print(info["meta"]) if "acl_delta" in info: show_diff(self.stdout, info["acl_delta"])
def merge_conf_files(dest, configs, dry_run=False, banner_comment=None): # Parse all config files cfgs = [conf.data for conf in configs] # Merge all config files: merged_cfg = merge_conf_dicts(*cfgs) if banner_comment: if not banner_comment.startswith("#"): banner_comment = "#" + banner_comment inject_section_comments(merged_cfg.setdefault(GLOBAL_STANZA, {}), prepend=[banner_comment]) # Either show the diff (dry-run mode) or write to the destination file if dry_run and dest.is_file(): if os.path.isfile(dest.name): dest_cfg = dest.data else: dest_cfg = {} show_diff(sys.stdout, compare_cfgs(merged_cfg, dest_cfg), headers=(dest.name, dest.name + "-new")) return SMART_UPDATE return dest.dump(merged_cfg)
def _do_promote_list(self, cfg_src, cfg_tgt, args): out_src = deepcopy(cfg_src) out_cfg = deepcopy(cfg_tgt) diff = [op for op in compare_cfgs(cfg_tgt, cfg_src, allow_level0=False) if op.tag in (DIFF_OP_INSERT, DIFF_OP_REPLACE)] for op in diff: if self.stanza_filters.match(op.location.stanza) ^ args.invert_match: if args.verbose: show_diff(self.stdout, [op]) if isinstance(op.location, DiffStanza): # Move entire stanza out_cfg[op.location.stanza] = self.combine_stanza(op.a, op.b) del out_src[op.location.stanza] else: # Move key out_cfg[op.location.stanza][op.location.key] = op.b del out_src[op.location.stanza][op.location.key] # If last remaining key in the src stanza? Then delete the entire stanza if not out_src[op.location.stanza]: del out_src[op.location.stanza] return (out_src, out_cfg)
def run(self, args): ''' Compare two configuration files. ''' args.conf1.set_parser_option(keep_comments=args.comments) args.conf2.set_parser_option(keep_comments=args.comments) cfg1 = args.conf1.data cfg2 = args.conf2.data diffs = compare_cfgs(cfg1, cfg2) rc = show_diff(args.output, diffs, headers=(args.conf1.name, args.conf2.name)) if rc == EXIT_CODE_DIFF_EQUAL: self.stderr.write("Files are the same.\n") elif rc == EXIT_CODE_DIFF_NO_COMMON: self.stderr.write("No common stanzas between files.\n") return rc
def run(self, args): if args.explode_default: # Is this the SAME as exploding the defaults AFTER the merge?; # I think NOT. Needs testing cfgs = [explode_default_stanza(conf.data) for conf in args.conf] else: cfgs = [conf.data for conf in args.conf] # Merge all config files: default_cfg = merge_conf_dicts(*cfgs) del cfgs local_cfg = args.target.data orig_cfg = dict(args.target.data) if args.explode_default: # Make a skeleton default dict; at the highest level, that ensure that all default default_stanza = default_cfg.get(GLOBAL_STANZA, default_cfg.get("default")) skeleton_default = dict([(k, {}) for k in args.target.data]) skeleton_default = explode_default_stanza(skeleton_default, default_stanza) default_cfg = merge_conf_dicts(skeleton_default, default_cfg) local_cfg = explode_default_stanza(local_cfg) local_cfg = explode_default_stanza(local_cfg, default_stanza) minz_cfg = dict(local_cfg) # This may be a bit too simplistic. Weird interplay may exit between if [default] stanza # and ocal [Upstream] stanza line up, but [Upstream] in our default file does not. # XXX: Add a unit test! diffs = compare_cfgs(default_cfg, local_cfg, allow_level0=False) for op in diffs: if op.tag == DIFF_OP_DELETE: # This is normal. Don't expect all default content to be mirrored into local continue elif op.tag == DIFF_OP_EQUAL: if isinstance(op.location, DiffStanza): del minz_cfg[op.location.stanza] else: # Todo: Only preserve keys for stanzas where at least 1 key has been modified if match_bwlist(op.location.key, args.preserve_key): ''' self.stderr.write("Skipping key [PRESERVED] [{0}] key={1} value={2!r}\n" "".format(op.location.stanza, op.location.key, op.a)) ''' continue # pragma: no cover (peephole optimization) del minz_cfg[op.location.stanza][op.location.key] # If that was the last remaining key in the stanza, delete the entire stanza if not _drop_stanza_comments(minz_cfg[op.location.stanza]): del minz_cfg[op.location.stanza] elif op.tag == DIFF_OP_INSERT: ''' self.stderr.write("Keeping local change: <{0}> {1!r}\n-{2!r}\n+{3!r}\n\n\n".format( op.tag, op.location, op.b, op.a)) ''' continue elif op.tag == DIFF_OP_REPLACE: ''' self.stderr.write("Keep change: <{0}> {1!r}\n-{2!r}\n+{3!r}\n\n\n".format( op.tag, op.location, op.b, op.a)) ''' continue if args.dry_run: if args.explode_default: rc = show_diff(self.stdout, compare_cfgs(orig_cfg, minz_cfg), headers=(args.target.name, args.target.name + "-new")) else: rc = show_diff(self.stdout, compare_cfgs(local_cfg, default_cfg), headers=(args.target.name, args.target.name + "-new")) return rc if args.output: args.output.dump(minz_cfg) else: args.target.dump(minz_cfg) '''
def _do_promote_interactive(self, cfg_src, cfg_tgt, args): ''' Interactively "promote" settings from one configuration file into another Model after git's "patch" mode, from git docs: This lets you choose one path out of a status like selection. After choosing the path, it presents the diff between the index and the working tree file and asks you if you want to stage the change of each hunk. You can select one of the following options and type return: y - stage this hunk n - do not stage this hunk q - quit; do not stage this hunk or any of the remaining ones a - stage this hunk and all later hunks in the file d - do not stage this hunk or any of the later hunks in the file g - select a hunk to go to / - search for a hunk matching the given regex j - leave this hunk undecided, see next undecided hunk J - leave this hunk undecided, see next hunk k - leave this hunk undecided, see previous undecided hunk K - leave this hunk undecided, see previous hunk s - split the current hunk into smaller hunks e - manually edit the current hunk ? - print help Note: In git's "edit" mode you are literally editing a patch file, so you can modify both the working tree file as well as the file that's being staged. While this is nifty, as git's own documentation points out (in other places), that "some changes may have confusing results". Therefore, it probably makes sense to limit what the user can edit. ============================================================================================ Options we may be able to support: Pri k Description --- - ----------- [1] y - stage this section or key [1] n - do not stage this section or key [1] q - quit; do not stage this or any of the remaining sections or attributes [2] a - stage this section or key and all later sections in the file [2] d - do not stage this section or key or any of the later section or key in the file [1] s - split the section into individual attributes [3] e - edit the current section or key [2] ? - print help Q: Is it less confusing to the user to adopt the 'local' and 'default' paradigm here? Even though we know that change promotions will not *always* be between default and local. (We can and should assume some familiarity with Splunk conf terms, less so than familiarity with git lingo.) ''' def prompt_yes_no(prompt): while True: r = input(prompt + " (y/n)") if r.lower().startswith("y"): return True elif r.lower().startswith("n"): return False out_src = deepcopy(cfg_src) out_cfg = deepcopy(cfg_tgt) ### Todo: IMPLEMENT A MANUAL MERGE/DIFF HERE: # What ever is migrated, move it OUT of cfg_src, and into cfg_tgt diff = compare_cfgs(cfg_tgt, cfg_src, allow_level0=False) for op in diff: if op.tag == DIFF_OP_DELETE: # This is normal. Not all default entries will be updated in local. continue elif op.tag == DIFF_OP_EQUAL: # Q: Should we simply remove everything from the source file that already lines # up with the target? (Probably?) For now just skip... if prompt_yes_no("Remove matching entry {0} ".format( op.location)): if isinstance(op.location, DiffStanza): del out_src[op.location.stanza] else: del out_src[op.location.stanza][op.location.key] else: ''' self.stderr.write("Found change: <{0}> {1!r}\n-{2!r}\n+{3!r}\n\n\n" .format(op.tag, op.location, op.b, op.a)) ''' if isinstance(op.location, DiffStanza): # Move entire stanza show_diff(self.stdout, [op]) if prompt_yes_no("Apply [{0}]".format( op.location.stanza)): out_cfg[op.location.stanza] = op.a del out_src[op.location.stanza] else: show_diff(self.stdout, [op]) if prompt_yes_no("Apply [{0}] {1}".format( op.location.stanza, op.location.key)): # Move key out_cfg[op.location.stanza][op.location.key] = op.a del out_src[op.location.stanza][op.location.key] # If last remaining key in the src stanza? Then delete the entire stanza if not out_src[op.location.stanza]: del out_src[op.location.stanza] return (out_src, out_cfg)
def run(self, args): if isinstance(args.target, ConfDirProxy): # If a directory is given instead of a target file, then assume the source filename # and target filename are the same. # Also handle local/default meta: e.g.: ksconf promote local.meta . source_basename = os.path.basename(args.source.name) if source_basename == "local.meta": args.target = args.target.get_file("default.meta") else: args.target = args.target.get_file(source_basename) del source_basename if not os.path.isfile(args.target.name): self.stdout.write( "Target file {} does not exist. Moving source file {} to the target." .format(args.target.name, args.source.name)) # For windows: Close out any open file descriptors first args.target.close() args.source.close() if args.keep: shutil.copy2(args.source.name, args.target.name) else: shutil.move(args.source.name, args.target.name) return # If src/dest are the same, then the file ends up being deleted. Whoops! if _samefile(args.source.name, args.target.name): self.stderr.write( "Aborting. SOURCE and TARGET are the same file!\n") return EXIT_CODE_FAILED_SAFETY_CHECK fp_source = file_fingerprint(args.source.name) fp_target = file_fingerprint(args.target.name) # Todo: Add a safety check prevent accidental merge of unrelated files. # Scenario: promote local/props.conf into default/transforms.conf # Possible check (1) Are basenames are different? (props.conf vs transforms.conf) # Possible check (2) Are there key's in common? (DEST_KEY vs REPORT) # Using #1 for now, consider if there's value in #2 bn_source = os.path.basename(args.source.name) bn_target = os.path.basename(args.target.name) if bn_source.endswith(".meta") and bn_target.endswith(".meta"): # Allow local.meta -> default.meta without --force or a warning message pass elif bn_source != bn_target: # Todo: Allow for interactive prompting when in interactive but not force mode. if args.force: self.stderr.write( "Promoting content across conf file types ({0} --> {1}) because the " "'--force' CLI option was set.\n".format( bn_source, bn_target)) else: self.stderr.write( "Refusing to promote content between different types of configuration " "files. {0} --> {1} If this is intentional, override this safety" "check with '--force'\n".format(bn_source, bn_target)) return EXIT_CODE_FAILED_SAFETY_CHECK # Todo: Preserve comments in the TARGET file. Worry with promoting of comments later... # Parse all config files cfg_src = args.source.data cfg_tgt = args.target.data if not cfg_src: self.stderr.write( "No settings in {}. Nothing to promote.\n".format( args.source.name)) return EXIT_CODE_NOTHING_TO_DO if args.mode == "ask": # Show a summary of how many new stanzas would be copied across; how many key changes. # And either accept all (batch) or pick selectively (batch) delta = compare_cfgs(cfg_tgt, cfg_src, allow_level0=False) delta = [op for op in delta if op.tag != DIFF_OP_DELETE] summarize_cfg_diffs(delta, self.stderr) while True: resp = input("Would you like to apply ALL changes? (y/n/d/q)") resp = resp[:1].lower() if resp == 'q': return EXIT_CODE_USER_QUIT elif resp == 'd': show_diff(self.stdout, delta, headers=(args.source.name, args.target.name)) elif resp == 'y': args.mode = "batch" break elif resp == 'n': args.mode = "interactive" break if args.mode == "interactive": (cfg_final_src, cfg_final_tgt) = self._do_promote_interactive( cfg_src, cfg_tgt, args) else: (cfg_final_src, cfg_final_tgt) = self._do_promote_automatic( cfg_src, cfg_tgt, args) # Minimize race condition: Do file mtime/hash check here. Abort on external change. # Todo: Eventually use temporary files and atomic renames to further minimize the risk # Todo: Make backup '.bak' files (user configurable) # Todo: Avoid rewriting files if NO changes were made. (preserve prior backups) # Todo: Restore file modes and such if file_fingerprint(args.source.name, fp_source): self.stderr.write( "Aborting! External source file changed: {0}\n".format( args.source.name)) return EXIT_CODE_EXTERNAL_FILE_EDIT if file_fingerprint(args.target.name, fp_target): self.stderr.write( "Aborting! External target file changed: {0}\n".format( args.target.name)) return EXIT_CODE_EXTERNAL_FILE_EDIT # Reminder: conf entries are being removed from source and promoted into target args.target.dump(cfg_final_tgt) if not args.keep: # If --keep is set, we never touch the source file. if cfg_final_src: args.source.dump(cfg_final_src) else: # Config file is empty. Should we write an empty file, or remove it? if args.keep_empty: args.source.dump(cfg_final_src) else: args.source.unlink()