Enable BytesWarnings.
[portage.git] / bin / dispatch-conf
1 #!/usr/bin/python -bbO
2 # Copyright 1999-2014 Gentoo Foundation
3 # Distributed under the terms of the GNU General Public License v2
4
5 #
6 # dispatch-conf -- Integrate modified configs, post-emerge
7 #
8 #  Jeremy Wohl (http://igmus.org)
9 #
10 # TODO
11 #  dialog menus
12 #
13
14 from __future__ import print_function
15
16 from stat import ST_GID, ST_MODE, ST_UID
17 from random import random
18 import atexit, re, shutil, stat, sys
19 from os import path as osp
20 pym_path = osp.join(osp.dirname(osp.dirname(osp.realpath(__file__))), "pym")
21 sys.path.insert(0, pym_path)
22 import portage
23 portage._internal_caller = True
24 from portage import os
25 from portage import _unicode_decode
26 from portage.dispatch_conf import diffstatusoutput
27 from portage.process import find_binary, spawn
28
29 FIND_EXTANT_CONFIGS  = "find '%s' %s -name '._cfg????_%s' ! -name '.*~' ! -iname '.*.bak' -print"
30 DIFF_CONTENTS        = "diff -Nu '%s' '%s'"
31
32 # We need a secure scratch dir and python does silly verbose errors on the use of tempnam
33 oldmask = os.umask(0o077)
34 SCRATCH_DIR = None
35 while SCRATCH_DIR is None:
36     try:
37         mydir = "/tmp/dispatch-conf."
38         for x in range(0,8):
39             if int(random() * 3) == 0:
40                 mydir += chr(int(65+random()*26.0))
41             elif int(random() * 2) == 0:
42                 mydir += chr(int(97+random()*26.0))
43             else:
44                 mydir += chr(int(48+random()*10.0))
45         if os.path.exists(mydir):
46             continue
47         os.mkdir(mydir)
48         SCRATCH_DIR = mydir
49     except OSError as e:
50         if e.errno != 17:
51             raise
52 os.umask(oldmask)
53
54 # Ensure the scratch dir is deleted
55 def cleanup(mydir=SCRATCH_DIR):
56     shutil.rmtree(mydir)
57 atexit.register(cleanup)
58
59 MANDATORY_OPTS = [ 'archive-dir', 'diff', 'replace-cvs', 'replace-wscomments', 'merge' ]
60
61 def cmd_var_is_valid(cmd):
62     """
63     Return true if the first whitespace-separated token contained
64     in cmd is an executable file, false otherwise.
65     """
66     cmd = portage.util.shlex_split(cmd)
67     if not cmd:
68         return False
69
70     if os.path.isabs(cmd[0]):
71         return os.access(cmd[0], os.EX_OK)
72
73     return find_binary(cmd[0]) is not None
74
75 class dispatch:
76     options = {}
77
78     def grind (self, config_paths):
79         confs = []
80         count = 0
81
82         config_root = portage.settings["EPREFIX"] or os.sep
83         self.options = portage.dispatch_conf.read_config(MANDATORY_OPTS)
84
85         if "log-file" in self.options:
86             if os.path.isfile(self.options["log-file"]):
87                 shutil.copy(self.options["log-file"], self.options["log-file"] + '.old')
88             if os.path.isfile(self.options["log-file"]) \
89                or not os.path.exists(self.options["log-file"]):
90                 open(self.options["log-file"], 'w').close() # Truncate it
91                 os.chmod(self.options["log-file"], 0o600)
92         else:
93             self.options["log-file"] = "/dev/null"
94
95         pager = self.options.get("pager")
96         if pager is None or not cmd_var_is_valid(pager):
97             pager = os.environ.get("PAGER")
98             if pager is None or not cmd_var_is_valid(pager):
99                 pager = "cat"
100
101         pager_basename = os.path.basename(portage.util.shlex_split(pager)[0])
102         if pager_basename == "less":
103             less_opts = self.options.get("less-opts")
104             if less_opts is not None and less_opts.strip():
105                 pager += " " + less_opts
106
107         if pager_basename == "cat":
108             pager = ""
109         else:
110             pager = " | " + pager
111
112         #
113         # Build list of extant configs
114         #
115
116         for path in config_paths:
117             path = portage.normalize_path(
118                  os.path.join(config_root, path.lstrip(os.sep)))
119             try:
120                 mymode = os.stat(path).st_mode
121             except OSError:
122                 continue
123             basename = "*"
124             find_opts = "-name '.*' -type d -prune -o"
125             if not stat.S_ISDIR(mymode):
126                 path, basename = os.path.split(path)
127                 find_opts = "-maxdepth 1"
128
129             with os.popen(FIND_EXTANT_CONFIGS %
130                 (path, find_opts, basename)) as proc:
131                 confs += self.massage(proc.readlines())
132
133         if self.options['use-rcs'] == 'yes':
134             for rcs_util in ("rcs", "ci", "co", "rcsmerge"):
135                 if not find_binary(rcs_util):
136                     print('dispatch-conf: Error finding all RCS utils and " + \
137                         "use-rcs=yes in config; fatal', file=sys.stderr)
138                     return False
139
140
141         # config file freezing support
142         frozen_files = set(self.options.get("frozen-files", "").split())
143         auto_zapped = []
144         protect_obj = portage.util.ConfigProtect(
145             config_root, config_paths,
146             portage.util.shlex_split(
147             portage.settings.get('CONFIG_PROTECT_MASK', '')))
148
149         def diff(file1, file2):
150             return diffstatusoutput(DIFF_CONTENTS, file1, file2)
151
152         #
153         # Remove new configs identical to current
154         #                  and
155         # Auto-replace configs a) whose differences are simply CVS interpolations,
156         #                  or  b) whose differences are simply ws or comments,
157         #                  or  c) in paths now unprotected by CONFIG_PROTECT_MASK,
158         #
159
160         def f (conf):
161             mrgconf = re.sub(r'\._cfg', '._mrg', conf['new'])
162             archive = os.path.join(self.options['archive-dir'], conf['current'].lstrip('/'))
163             if self.options['use-rcs'] == 'yes':
164                 mrgfail = portage.dispatch_conf.rcs_archive(archive, conf['current'], conf['new'], mrgconf)
165             else:
166                 mrgfail = portage.dispatch_conf.file_archive(archive, conf['current'], conf['new'], mrgconf)
167             if os.path.exists(archive + '.dist'):
168                 unmodified = len(diff(conf['current'], archive + '.dist')[1]) == 0
169             else:
170                 unmodified = 0
171             if os.path.exists(mrgconf):
172                 if mrgfail or len(diff(conf['new'], mrgconf)[1]) == 0:
173                     os.unlink(mrgconf)
174                     newconf = conf['new']
175                 else:
176                     newconf = mrgconf
177             else:
178                 newconf = conf['new']
179
180             if newconf == mrgconf and \
181                 self.options.get('ignore-previously-merged') != 'yes' and \
182                 os.path.exists(archive+'.dist') and \
183                 len(diff(archive+'.dist', conf['new'])[1]) == 0:
184                 # The current update is identical to the archived .dist
185                 # version that has previously been merged.
186                 os.unlink(mrgconf)
187                 newconf = conf['new']
188
189             mystatus, myoutput = diff(conf['current'], newconf)
190             myoutput_len = len(myoutput)
191             same_file = 0 == myoutput_len
192             if mystatus >> 8 == 2:
193                 # Binary files differ
194                 same_cvs = False
195                 same_wsc = False
196             else:
197                 # Extract all the normal diff lines (ignore the headers).
198                 mylines = re.findall('^[+-][^\n+-].*$', myoutput, re.MULTILINE)
199
200                 # Filter out all the cvs headers
201                 cvs_header = re.compile('# [$]Header:')
202                 cvs_lines = list(filter(cvs_header.search, mylines))
203                 same_cvs = len(mylines) == len(cvs_lines)
204
205                 # Filter out comments and whitespace-only changes.
206                 # Note: be nice to also ignore lines that only differ in whitespace...
207                 wsc_lines = []
208                 for x in ['^[-+]\s*#', '^[-+]\s*$']:
209                    wsc_lines += list(filter(re.compile(x).match, mylines))
210                 same_wsc = len(mylines) == len(wsc_lines)
211
212             # Do options permit?
213             same_cvs = same_cvs and self.options['replace-cvs'] == 'yes'
214             same_wsc = same_wsc and self.options['replace-wscomments'] == 'yes'
215             unmodified = unmodified and self.options['replace-unmodified'] == 'yes'
216
217             if same_file:
218                 os.unlink (conf ['new'])
219                 self.post_process(conf['current'])
220                 if os.path.exists(mrgconf):
221                     os.unlink(mrgconf)
222                 return False
223             elif conf['current'] in frozen_files:
224                 """Frozen files are automatically zapped. The new config has
225                 already been archived with a .new suffix.  When zapped, it is
226                 left with the .new suffix (post_process is skipped), since it
227                 hasn't been merged into the current config."""
228                 auto_zapped.append(conf['current'])
229                 os.unlink(conf['new'])
230                 try:
231                     os.unlink(mrgconf)
232                 except OSError:
233                     pass
234                 return False
235             elif unmodified or same_cvs or same_wsc or \
236                 not protect_obj.isprotected(conf['current']):
237                 self.replace(newconf, conf['current'])
238                 self.post_process(conf['current'])
239                 if newconf == mrgconf:
240                     os.unlink(conf['new'])
241                 elif os.path.exists(mrgconf):
242                     os.unlink(mrgconf)
243                 return False
244             else:
245                 return True
246
247         confs = [x for x in confs if f(x)]
248
249         #
250         # Interactively process remaining
251         #
252
253         valid_input = "qhtnmlezu"
254
255         for conf in confs:
256             count = count + 1
257
258             newconf = conf['new']
259             mrgconf = re.sub(r'\._cfg', '._mrg', newconf)
260             if os.path.exists(mrgconf):
261                 newconf = mrgconf
262             show_new_diff = 0
263
264             while 1:
265                 clear_screen()
266                 if show_new_diff:
267                     cmd = self.options['diff'] % (conf['new'], mrgconf)
268                     cmd += pager
269                     spawn_shell(cmd)
270                     show_new_diff = 0
271                 else:
272                     cmd = self.options['diff'] % (conf['current'], newconf)
273                     cmd += pager
274                     spawn_shell(cmd)
275
276                 print()
277                 print('>> (%i of %i) -- %s' % (count, len(confs), conf ['current']))
278                 print('>> q quit, h help, n next, e edit-new, z zap-new, u use-new\n   m merge, t toggle-merge, l look-merge: ', end=' ')
279
280                 # In some cases getch() will return some spurious characters
281                 # that do not represent valid input. If we don't validate the
282                 # input then the spurious characters can cause us to jump
283                 # back into the above "diff" command immediatly after the user
284                 # has exited it (which can be quite confusing and gives an
285                 # "out of control" feeling).
286                 while True:
287                     c = getch()
288                     if c in valid_input:
289                         sys.stdout.write('\n')
290                         sys.stdout.flush()
291                         break
292
293                 if c == 'q':
294                     sys.exit (0)
295                 if c == 'h':
296                     self.do_help ()
297                     continue
298                 elif c == 't':
299                     if newconf == mrgconf:
300                         newconf = conf['new']
301                     elif os.path.exists(mrgconf):
302                         newconf = mrgconf
303                     continue
304                 elif c == 'n':
305                     break
306                 elif c == 'm':
307                     merged = SCRATCH_DIR+"/"+os.path.basename(conf['current'])
308                     print()
309                     ret = os.system (self.options['merge'] % (merged, conf ['current'], newconf))
310                     ret = os.WEXITSTATUS(ret)
311                     if ret < 2:
312                         ret = 0
313                     if ret:
314                         print("Failure running 'merge' command")
315                         continue
316                     shutil.copyfile(merged, mrgconf)
317                     os.remove(merged)
318                     mystat = os.lstat(conf['new'])
319                     os.chmod(mrgconf, mystat[ST_MODE])
320                     os.chown(mrgconf, mystat[ST_UID], mystat[ST_GID])
321                     newconf = mrgconf
322                     continue
323                 elif c == 'l':
324                     show_new_diff = 1
325                     continue
326                 elif c == 'e':
327                     if 'EDITOR' not in os.environ:
328                         os.environ['EDITOR']='nano'
329                     os.system(os.environ['EDITOR'] + ' ' + newconf)
330                     continue
331                 elif c == 'z':
332                     os.unlink(conf['new'])
333                     if os.path.exists(mrgconf):
334                         os.unlink(mrgconf)
335                     break
336                 elif c == 'u':
337                     self.replace(newconf, conf ['current'])
338                     self.post_process(conf['current'])
339                     if newconf == mrgconf:
340                         os.unlink(conf['new'])
341                     elif os.path.exists(mrgconf):
342                         os.unlink(mrgconf)
343                     break
344                 else:
345                     raise AssertionError("Invalid Input: %s" % c)
346
347         if auto_zapped:
348             print()
349             print(" One or more updates are frozen and have been automatically zapped:")
350             print()
351             for frozen in auto_zapped:
352                 print("  * '%s'" % frozen)
353             print()
354
355     def replace (self, newconf, curconf):
356         """Replace current config with the new/merged version.  Also logs
357         the diff of what changed into the configured log file."""
358         os.system((DIFF_CONTENTS % (curconf, newconf)) + '>>' + self.options["log-file"])
359         try:
360             os.rename(newconf, curconf)
361         except (IOError, os.error) as why:
362             print('dispatch-conf: Error renaming %s to %s: %s; fatal' % \
363                   (newconf, curconf, str(why)), file=sys.stderr)
364
365
366     def post_process(self, curconf):
367         archive = os.path.join(self.options['archive-dir'], curconf.lstrip('/'))
368         if self.options['use-rcs'] == 'yes':
369             portage.dispatch_conf.rcs_archive_post_process(archive)
370         else:
371             portage.dispatch_conf.file_archive_post_process(archive)
372
373
374     def massage (self, newconfigs):
375         """Sort, rstrip, remove old versions, break into triad hash.
376
377         Triad is dictionary of current (/etc/make.conf), new (/etc/._cfg0003_make.conf)
378         and dir (/etc).
379
380         We keep ._cfg0002_conf over ._cfg0001_conf and ._cfg0000_conf.
381         """
382         h = {}
383         configs = []
384         newconfigs.sort ()
385
386         for nconf in newconfigs:
387             nconf = nconf.rstrip ()
388             conf  = re.sub (r'\._cfg\d+_', '', nconf)
389             dirname   = os.path.dirname(nconf)
390             conf_map  = {
391                 'current' : conf,
392                 'dir'     : dirname,
393                 'new'     : nconf,
394             }
395
396             if conf in h:
397                 mrgconf = re.sub(r'\._cfg', '._mrg', h[conf]['new'])
398                 if os.path.exists(mrgconf):
399                     os.unlink(mrgconf)
400                 os.unlink(h[conf]['new'])
401                 h[conf].update(conf_map)
402             else:
403                 h[conf] = conf_map
404                 configs.append(conf_map)
405
406         return configs
407
408
409     def do_help (self):
410         print()
411         print()
412
413         print('  u -- update current config with new config and continue')
414         print('  z -- zap (delete) new config and continue')
415         print('  n -- skip to next config, leave all intact')
416         print('  e -- edit new config')
417         print('  m -- interactively merge current and new configs')
418         print('  l -- look at diff between pre-merged and merged configs')
419         print('  t -- toggle new config between merged and pre-merged state')
420         print('  h -- this screen')
421         print('  q -- quit')
422
423         print(); print('press any key to return to diff...', end=' ')
424
425         getch ()
426
427
428 def getch ():
429     # from ASPN - Danny Yoo
430     #
431     import tty, termios
432
433     fd = sys.stdin.fileno()
434     old_settings = termios.tcgetattr(fd)
435     try:
436         tty.setraw(sys.stdin.fileno())
437         ch = sys.stdin.read(1)
438     finally:
439         termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
440     return ch
441
442 def clear_screen():
443     try:
444         import curses
445         try:
446             curses.setupterm()
447             sys.stdout.write(_unicode_decode(curses.tigetstr("clear")))
448             sys.stdout.flush()
449             return
450         except curses.error:
451             pass
452     except ImportError:
453         pass
454     os.system("clear 2>/dev/null")
455
456 shell = os.environ.get("SHELL")
457 if not shell or not os.access(shell, os.EX_OK):
458     shell = find_binary("sh")
459
460 def spawn_shell(cmd):
461     if shell:
462         sys.__stdout__.flush()
463         sys.__stderr__.flush()
464         spawn([shell, "-c", cmd], env=os.environ,
465             fd_pipes = {  0 : portage._get_stdin().fileno(),
466                           1 : sys.__stdout__.fileno(),
467                           2 : sys.__stderr__.fileno()})
468     else:
469         os.system(cmd)
470
471 def usage(argv):
472     print('dispatch-conf: sane configuration file update\n')
473     print('Usage: dispatch-conf [config dirs]\n')
474     print('See the dispatch-conf(1) man page for more details')
475     sys.exit(os.EX_OK)
476
477 for x in sys.argv:
478     if x in ('-h', '--help'):
479         usage(sys.argv)
480     elif x in ('--version'):
481         print("Portage", portage.VERSION)
482         sys.exit(os.EX_OK)
483
484 # run
485 d = dispatch ()
486
487 if len(sys.argv) > 1:
488     # for testing
489     d.grind(sys.argv[1:])
490 else:
491     d.grind(portage.util.shlex_split(
492         portage.settings.get('CONFIG_PROTECT', '')))