aboutsummaryrefslogtreecommitdiff
blob: 10455f444633d6737035d26871fd08fb5c54f484 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
#!/usr/bin/python -O
# Copyright 1999-2013 Gentoo Foundation
# Distributed under the terms of the GNU General Public License v2

#
# dispatch-conf -- Integrate modified configs, post-emerge
#
#  Jeremy Wohl (http://igmus.org)
#
# TODO
#  dialog menus
#

from __future__ import print_function

from stat import ST_GID, ST_MODE, ST_UID
from random import random
import atexit, re, shutil, stat, sys
from os import path as osp
pym_path = osp.join(osp.dirname(osp.dirname(osp.realpath(__file__))), "pym")
sys.path.insert(0, pym_path)
import portage
portage._internal_caller = True
from portage import os
from portage import _unicode_decode
from portage.dispatch_conf import diffstatusoutput
from portage.process import find_binary, spawn

FIND_EXTANT_CONFIGS  = "find '%s' %s -name '._cfg????_%s' ! -name '.*~' ! -iname '.*.bak' -print"
DIFF_CONTENTS        = "diff -Nu '%s' '%s'"

# We need a secure scratch dir and python does silly verbose errors on the use of tempnam
oldmask = os.umask(0o077)
SCRATCH_DIR = None
while SCRATCH_DIR is None:
    try:
        mydir = "/tmp/dispatch-conf."
        for x in range(0,8):
            if int(random() * 3) == 0:
                mydir += chr(int(65+random()*26.0))
            elif int(random() * 2) == 0:
                mydir += chr(int(97+random()*26.0))
            else:
                mydir += chr(int(48+random()*10.0))
        if os.path.exists(mydir):
            continue
        os.mkdir(mydir)
        SCRATCH_DIR = mydir
    except OSError as e:
        if e.errno != 17:
            raise
os.umask(oldmask)

# Ensure the scratch dir is deleted
def cleanup(mydir=SCRATCH_DIR):
    shutil.rmtree(mydir)
atexit.register(cleanup)

MANDATORY_OPTS = [ 'archive-dir', 'diff', 'replace-cvs', 'replace-wscomments', 'merge' ]

def cmd_var_is_valid(cmd):
    """
    Return true if the first whitespace-separated token contained
    in cmd is an executable file, false otherwise.
    """
    cmd = portage.util.shlex_split(cmd)
    if not cmd:
        return False

    if os.path.isabs(cmd[0]):
        return os.access(cmd[0], os.EX_OK)

    return find_binary(cmd[0]) is not None

class dispatch:
    options = {}

    def grind (self, config_paths):
        confs = []
        count = 0

        config_root = portage.settings["EPREFIX"] or os.sep
        self.options = portage.dispatch_conf.read_config(MANDATORY_OPTS)

        if "log-file" in self.options:
            if os.path.isfile(self.options["log-file"]):
                shutil.copy(self.options["log-file"], self.options["log-file"] + '.old')
            if os.path.isfile(self.options["log-file"]) \
               or not os.path.exists(self.options["log-file"]):
                open(self.options["log-file"], 'w').close() # Truncate it
                os.chmod(self.options["log-file"], 0o600)
        else:
            self.options["log-file"] = "/dev/null"

        pager = self.options.get("pager")
        if pager is None or not cmd_var_is_valid(pager):
            pager = os.environ.get("PAGER")
            if pager is None or not cmd_var_is_valid(pager):
                pager = "cat"

        pager_basename = os.path.basename(portage.util.shlex_split(pager)[0])
        if pager_basename == "less":
            less_opts = self.options.get("less-opts")
            if less_opts is not None and less_opts.strip():
                pager += " " + less_opts

        if pager_basename == "cat":
            pager = ""
        else:
            pager = " | " + pager

        #
        # Build list of extant configs
        #

        for path in config_paths:
            path = portage.normalize_path(
                 os.path.join(config_root, path.lstrip(os.sep)))
            try:
                mymode = os.stat(path).st_mode
            except OSError:
                continue
            basename = "*"
            find_opts = "-name '.*' -type d -prune -o"
            if not stat.S_ISDIR(mymode):
                path, basename = os.path.split(path)
                find_opts = "-maxdepth 1"

            with os.popen(FIND_EXTANT_CONFIGS %
                (path, find_opts, basename)) as proc:
                confs += self.massage(proc.readlines())

        if self.options['use-rcs'] == 'yes':
            for rcs_util in ("rcs", "ci", "co", "rcsmerge"):
                if not find_binary(rcs_util):
                    print('dispatch-conf: Error finding all RCS utils and " + \
                        "use-rcs=yes in config; fatal', file=sys.stderr)
                    return False


        # config file freezing support
        frozen_files = set(self.options.get("frozen-files", "").split())
        auto_zapped = []
        protect_obj = portage.util.ConfigProtect(
            config_root, config_paths,
            portage.util.shlex_split(
            portage.settings.get('CONFIG_PROTECT_MASK', '')))

        def diff(file1, file2):
            return diffstatusoutput(DIFF_CONTENTS, file1, file2)

        #
        # Remove new configs identical to current
        #                  and
        # Auto-replace configs a) whose differences are simply CVS interpolations,
        #                  or  b) whose differences are simply ws or comments,
        #                  or  c) in paths now unprotected by CONFIG_PROTECT_MASK,
        #

        def f (conf):
            mrgconf = re.sub(r'\._cfg', '._mrg', conf['new'])
            archive = os.path.join(self.options['archive-dir'], conf['current'].lstrip('/'))
            if self.options['use-rcs'] == 'yes':
                mrgfail = portage.dispatch_conf.rcs_archive(archive, conf['current'], conf['new'], mrgconf)
            else:
                mrgfail = portage.dispatch_conf.file_archive(archive, conf['current'], conf['new'], mrgconf)
            if os.path.exists(archive + '.dist'):
                unmodified = len(diff(conf['current'], archive + '.dist')[1]) == 0
            else:
                unmodified = 0
            if os.path.exists(mrgconf):
                if mrgfail or len(diff(conf['new'], mrgconf)[1]) == 0:
                    os.unlink(mrgconf)
                    newconf = conf['new']
                else:
                    newconf = mrgconf
            else:
                newconf = conf['new']

            if newconf == mrgconf and \
                self.options.get('ignore-previously-merged') != 'yes' and \
                os.path.exists(archive+'.dist') and \
                len(diff(archive+'.dist', conf['new'])[1]) == 0:
                # The current update is identical to the archived .dist
                # version that has previously been merged.
                os.unlink(mrgconf)
                newconf = conf['new']

            mystatus, myoutput = diff(conf['current'], newconf)
            myoutput_len = len(myoutput)
            same_file = 0 == myoutput_len
            if mystatus >> 8 == 2:
                # Binary files differ
                same_cvs = False
                same_wsc = False
            else:
                # Extract all the normal diff lines (ignore the headers).
                mylines = re.findall('^[+-][^\n+-].*$', myoutput, re.MULTILINE)

                # Filter out all the cvs headers
                cvs_header = re.compile('# [$]Header:')
                cvs_lines = list(filter(cvs_header.search, mylines))
                same_cvs = len(mylines) == len(cvs_lines)

                # Filter out comments and whitespace-only changes.
                # Note: be nice to also ignore lines that only differ in whitespace...
                wsc_lines = []
                for x in ['^[-+]\s*#', '^[-+]\s*$']:
                   wsc_lines += list(filter(re.compile(x).match, mylines))
                same_wsc = len(mylines) == len(wsc_lines)

            # Do options permit?
            same_cvs = same_cvs and self.options['replace-cvs'] == 'yes'
            same_wsc = same_wsc and self.options['replace-wscomments'] == 'yes'
            unmodified = unmodified and self.options['replace-unmodified'] == 'yes'

            if same_file:
                os.unlink (conf ['new'])
                self.post_process(conf['current'])
                if os.path.exists(mrgconf):
                    os.unlink(mrgconf)
                return False
            elif conf['current'] in frozen_files:
                """Frozen files are automatically zapped. The new config has
                already been archived with a .new suffix.  When zapped, it is
                left with the .new suffix (post_process is skipped), since it
                hasn't been merged into the current config."""
                auto_zapped.append(conf['current'])
                os.unlink(conf['new'])
                try:
                    os.unlink(mrgconf)
                except OSError:
                    pass
                return False
            elif unmodified or same_cvs or same_wsc or \
                not protect_obj.isprotected(conf['current']):
                self.replace(newconf, conf['current'])
                self.post_process(conf['current'])
                if newconf == mrgconf:
                    os.unlink(conf['new'])
                elif os.path.exists(mrgconf):
                    os.unlink(mrgconf)
                return False
            else:
                return True

        confs = [x for x in confs if f(x)]

        #
        # Interactively process remaining
        #

        valid_input = "qhtnmlezu"

        for conf in confs:
            count = count + 1

            newconf = conf['new']
            mrgconf = re.sub(r'\._cfg', '._mrg', newconf)
            if os.path.exists(mrgconf):
                newconf = mrgconf
            show_new_diff = 0

            while 1:
                clear_screen()
                if show_new_diff:
                    cmd = self.options['diff'] % (conf['new'], mrgconf)
                    cmd += pager
                    spawn_shell(cmd)
                    show_new_diff = 0
                else:
                    cmd = self.options['diff'] % (conf['current'], newconf)
                    cmd += pager
                    spawn_shell(cmd)

                print()
                print('>> (%i of %i) -- %s' % (count, len(confs), conf ['current']))
                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=' ')

                # In some cases getch() will return some spurious characters
                # that do not represent valid input. If we don't validate the
                # input then the spurious characters can cause us to jump
                # back into the above "diff" command immediatly after the user
                # has exited it (which can be quite confusing and gives an
                # "out of control" feeling).
                while True:
                    c = getch()
                    if c in valid_input:
                        sys.stdout.write('\n')
                        sys.stdout.flush()
                        break

                if c == 'q':
                    sys.exit (0)
                if c == 'h':
                    self.do_help ()
                    continue
                elif c == 't':
                    if newconf == mrgconf:
                        newconf = conf['new']
                    elif os.path.exists(mrgconf):
                        newconf = mrgconf
                    continue
                elif c == 'n':
                    break
                elif c == 'm':
                    merged = SCRATCH_DIR+"/"+os.path.basename(conf['current'])
                    print()
                    ret = os.system (self.options['merge'] % (merged, conf ['current'], newconf))
                    ret = os.WEXITSTATUS(ret)
                    if ret < 2:
                        ret = 0
                    if ret:
                        print("Failure running 'merge' command")
                        continue
                    shutil.copyfile(merged, mrgconf)
                    os.remove(merged)
                    mystat = os.lstat(conf['new'])
                    os.chmod(mrgconf, mystat[ST_MODE])
                    os.chown(mrgconf, mystat[ST_UID], mystat[ST_GID])
                    newconf = mrgconf
                    continue
                elif c == 'l':
                    show_new_diff = 1
                    continue
                elif c == 'e':
                    if 'EDITOR' not in os.environ:
                        os.environ['EDITOR']='nano'
                    os.system(os.environ['EDITOR'] + ' ' + newconf)
                    continue
                elif c == 'z':
                    os.unlink(conf['new'])
                    if os.path.exists(mrgconf):
                        os.unlink(mrgconf)
                    break
                elif c == 'u':
                    self.replace(newconf, conf ['current'])
                    self.post_process(conf['current'])
                    if newconf == mrgconf:
                        os.unlink(conf['new'])
                    elif os.path.exists(mrgconf):
                        os.unlink(mrgconf)
                    break
                else:
                    raise AssertionError("Invalid Input: %s" % c)

        if auto_zapped:
            print()
            print(" One or more updates are frozen and have been automatically zapped:")
            print()
            for frozen in auto_zapped:
                print("  * '%s'" % frozen)
            print()

    def replace (self, newconf, curconf):
        """Replace current config with the new/merged version.  Also logs
        the diff of what changed into the configured log file."""
        os.system((DIFF_CONTENTS % (curconf, newconf)) + '>>' + self.options["log-file"])
        try:
            os.rename(newconf, curconf)
        except (IOError, os.error) as why:
            print('dispatch-conf: Error renaming %s to %s: %s; fatal' % \
                  (newconf, curconf, str(why)), file=sys.stderr)


    def post_process(self, curconf):
        archive = os.path.join(self.options['archive-dir'], curconf.lstrip('/'))
        if self.options['use-rcs'] == 'yes':
            portage.dispatch_conf.rcs_archive_post_process(archive)
        else:
            portage.dispatch_conf.file_archive_post_process(archive)


    def massage (self, newconfigs):
        """Sort, rstrip, remove old versions, break into triad hash.

        Triad is dictionary of current (/etc/make.conf), new (/etc/._cfg0003_make.conf)
        and dir (/etc).

        We keep ._cfg0002_conf over ._cfg0001_conf and ._cfg0000_conf.
        """
        h = {}
        configs = []
        newconfigs.sort ()

        for nconf in newconfigs:
            nconf = nconf.rstrip ()
            conf  = re.sub (r'\._cfg\d+_', '', nconf)
            dirname   = os.path.dirname(nconf)
            conf_map  = {
                'current' : conf,
                'dir'     : dirname,
                'new'     : nconf,
            }

            if conf in h:
                mrgconf = re.sub(r'\._cfg', '._mrg', h[conf]['new'])
                if os.path.exists(mrgconf):
                    os.unlink(mrgconf)
                os.unlink(h[conf]['new'])
                h[conf].update(conf_map)
            else:
                h[conf] = conf_map
                configs.append(conf_map)

        return configs


    def do_help (self):
        print()
        print()

        print('  u -- update current config with new config and continue')
        print('  z -- zap (delete) new config and continue')
        print('  n -- skip to next config, leave all intact')
        print('  e -- edit new config')
        print('  m -- interactively merge current and new configs')
        print('  l -- look at diff between pre-merged and merged configs')
        print('  t -- toggle new config between merged and pre-merged state')
        print('  h -- this screen')
        print('  q -- quit')

        print(); print('press any key to return to diff...', end=' ')

        getch ()


def getch ():
    # from ASPN - Danny Yoo
    #
    import tty, termios

    fd = sys.stdin.fileno()
    old_settings = termios.tcgetattr(fd)
    try:
        tty.setraw(sys.stdin.fileno())
        ch = sys.stdin.read(1)
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
    return ch

def clear_screen():
    try:
        import curses
        try:
            curses.setupterm()
            sys.stdout.write(_unicode_decode(curses.tigetstr("clear")))
            sys.stdout.flush()
            return
        except curses.error:
            pass
    except ImportError:
        pass
    os.system("clear 2>/dev/null")

shell = os.environ.get("SHELL")
if not shell or not os.access(shell, os.EX_OK):
    shell = find_binary("sh")

def spawn_shell(cmd):
    if shell:
        sys.__stdout__.flush()
        sys.__stderr__.flush()
        spawn([shell, "-c", cmd], env=os.environ,
            fd_pipes = {  0 : portage._get_stdin().fileno(),
                          1 : sys.__stdout__.fileno(),
                          2 : sys.__stderr__.fileno()})
    else:
        os.system(cmd)

def usage(argv):
    print('dispatch-conf: sane configuration file update\n')
    print('Usage: dispatch-conf [config dirs]\n')
    print('See the dispatch-conf(1) man page for more details')
    sys.exit(os.EX_OK)

for x in sys.argv:
    if x in ('-h', '--help'):
        usage(sys.argv)
    elif x in ('--version'):
        print("Portage", portage.VERSION)
        sys.exit(os.EX_OK)

# run
d = dispatch ()

if len(sys.argv) > 1:
    # for testing
    d.grind(sys.argv[1:])
else:
    d.grind(portage.util.shlex_split(
        portage.settings.get('CONFIG_PROTECT', '')))