cli: enhancements of doom, v1
[srp3.git] / src / modules / srp / core.py
1 """Core classes and functions.
2
3 This module makes up the guts of the srp module (i.e., it contains all the
4 functions that actually DO STUFF using the other components of srp).
5
6 This module gets merged into the toplevel srp module.
7 """
8
9 import glob
10 import hashlib
11 import os
12 import pickle
13 import platform
14 import stat
15 import tarfile
16 import tempfile
17 import time
18 import types
19
20 import srp
21
22
23 class SrpObject:
24     """Base object for all srp objects.  Basically just adds some debugging
25     routines.
26
27     """
28     def __str__(self):
29         """This __str__ method is special in that it scales its verbosity
30         according to srp.params.verbosity.  A value of 0 or 1 results in
31         output identical to __repr__(), 2 results in additionally
32         including a __str__() of each data member.
33         
34         NOTE: The verbosity scaling is assuming that at 0, you're not
35               printing anything, and at 1 you want basic info.  2 and up
36               adds more and more until you drown in information.  ;-)
37
38         """
39         ret = repr(self)
40         if srp.params.verbosity <= 1:
41             return ret
42
43         # slice off the trailing '>'
44         ret = ret[:-1]
45         for k in dir(self):
46             v = getattr(self, k)
47             if k.startswith("_") or type(v) == types.MethodType:
48                 continue
49             ret+=", {}={}".format(k, v)
50         ret += '>'
51         return ret
52
53
54 class RunTimeParameters(SrpObject):
55     """Class representing the work to be done for this invocation of srp.
56
57     Each high-level operational mode (e.g., build) of srp has its own
58     class to handle it's parameters:
59
60       build - instance of BuildParameters
61
62       install - instance of InstallParameters
63
64       uninstall - instance of UninstallParameters
65
66       query - instance of QueryParameters
67
68       action - instance of ActionParameters
69
70     Global Parameters:
71
72       verbosity - Integer representing verbosity level.  0 is off, 1 is a
73           little debug, 2 is more, etc...
74
75       dry_run - Don't actually do anything, just print out what WOULD have
76           been done.  Since we cannot guarantee that all Feature
77           implementers will have appropriately checked this parameter, the
78           Feature funcs are not executed when dry_run is set.
79
80       root - Alternate root dir (like DESTDIR or SRP_ROOT_PREFIX).  Will
81           get set to "/" by default.
82
83       force - Forcefully do something that should otherwise not be done
84           (e.g., install even though dependencies aren't met, upgrade to
85           same version of a package).
86
87       options - List of of Features to be enabled (or disabled if prefixed
88           with "no_").  This list is used to modify the default list of
89           enabled Features at run-time.
90
91
92     FIXME: should force be global? or specific to install, perhaps with a
93            more detailed name?
94
95     """
96     def __init__(self):
97         # global params
98         self.verbosity = 0
99         self.dry_run = False
100         self.root = "/"
101         self.options = []
102
103         # mode param instances
104         self.build = None
105         self.install = None
106         self.uninstall = None
107         self.query = None
108         self.action = None
109
110     def __setattr__(self, name, value):
111         """This special __setattr__ method does some extra work if `root' is
112         being set.  Namely, it 1) ensures that the new rootdir exists, creating
113         it if needed, and 2) automatically re-invokes srp.db.load() if
114         it's already been loaded.
115
116         """
117         # set it
118         object.__setattr__(self, name, value)
119
120         # reload the database if we just modified 'root' and db module has
121         # already been loaded
122         if name == "root":
123             os.makedirs(value, exist_ok=True)
124             if hasattr(srp, "db"):
125                 srp.db.load()
126
127
128 # FIXME: where should this go?
129 def expand_path(path):
130     """Returns a single, expanded, absolute path.  `path' arg can be shell
131     glob, but must result in only a single match.  An exception is raised
132     on any globbing errors (e.g., not found, multiple matches) or if the
133     resulting path doesn't exist.
134
135     """
136     rv = glob.glob(path)
137     if not rv:
138         raise Exception("no such file - {}".format(path))
139
140     if len(rv) != 1:
141         raise Exception("glob had multiple matches - {}".format(path))
142
143     rv = os.path.abspath(rv[0])
144     return rv
145
146
147 class BuildParameters(SrpObject):
148     """Class representing the parameters for srp.build().
149
150     Data:
151
152       notes - Absolute path to the notes file on disk.
153
154       src - Either the absolute path to a source tarball or a directory
155           full of source code.  Defaults to the directory containing the
156           notes file.
157
158       extradir - Specifies an absolute path to a dir to be used to locate
159           extra files.  Defaults to directory containing the notes file.
160
161       copysrc - If True, the build will create a copy of the source tree
162           (i.e., so we don't modify an external source tree).  Defaults to
163           False.
164
165
166     FIXME: we can probably get rid of extradir at this point... it just
167            doesn't seem to make any sense now that we've nixed the idea of
168            source packages.
169     
170            actually, there is a use case still: notes file + external
171            source dir + dir of "extra files" (e.g., config files, patches)
172            where notes file isn't in same dir as extra files.
173     
174            also, files declared as extra_content in the notes file are
175            copied into the extra_content dir in the per-package build
176            tree.  w/out that, build_scripts won't be able to find their
177            extra config files in the case mentioned above...
178     
179     FIXME: Is the description of copysrc really needed here?  Isn't this
180            almost verbatim from the usage message in cli.py?
181
182     """
183     def __init__(self, notes, src=None, extradir=None, copysrc=False):
184         """The paths `notes', `src', and `extradir' can be specified as relative
185         paths, but will get stored away as absolute paths.
186
187         Paths can use shell globbing (e.g., src/foo/foo*.tar.*) but MUST
188         only result in a single match.
189
190         """
191         self.notes = expand_path(notes)
192
193         # if not set, default to directory containing notes file
194         try:
195             self.src = expand_path(src)
196         except:
197             self.src = os.path.dirname(self.notes)
198
199         # if not set, default to directory containing notes file
200         try:
201             self.extradir = expand_path(extradir)
202         except:
203             self.extradir = os.path.dirname(self.notes)
204
205         self.copysrc = copysrc
206
207
208 class InstallParameters(SrpObject):
209     """Class representing the parameters for srp.install().
210
211     Data:
212
213       pkg - Absolute path to a brp to be installed.
214
215       upgrade - Setting to False will disable upgrade logic and raise an
216           error if the specified package is already installed.  Defaults
217           to True.
218
219     """
220     def __init__(self, pkg, upgrade=True):
221         """The path `pkg' can be specified as a relative path and can use shell
222         globbing.
223
224         """
225         self.pkg = expand_path(pkg)
226         self.upgrade = upgrade
227
228
229 class QueryParameters(SrpObject):
230     """Class representing the parameters for srp.query().
231
232     Data:
233
234       types - List of types of results the user is asking for (e.g.,
235           [info, files]).
236
237       criteria - Dictionary of search criteria that would make a package
238           match (e.g., {"pkg": "*"}).
239
240     """
241     def __init__(self, types, criteria):
242         self.types = types
243         self.criteria = criteria
244
245
246 def build():
247     """Builds a package according to the RunTimeParameters instance
248     `srp.params'.  All work is stored in the features.WorkBag instance
249     `srp.work'.
250
251     """
252     # create our work instance
253     srp.work.build = srp.features.BuildWork()
254
255     # get some local refs with shorter names
256     n = srp.work.build.notes
257     funcs = srp.work.build.funcs
258     iter_funcs = srp.work.build.iter_funcs
259
260     # FIXME: should the core feature func untar the srp in a tmp dir? or
261     #        should we do that here and pass tmpdir in via our work
262     #        map...?  i think that's the only reason any of the build
263     #        funcs would need the tarfile instance...  might just boil
264     #        down to how determined i am to make the feature funcs do as
265     #        much of the work as possible...
266     #
267     #        it might also come down to duplicating code all over the
268     #        place... chances are, there's a bunch of places where we'll
269     #        need to create the tmpdir and extract a package's
270     #        files... in which case we'll rip that out of the core
271     #        feature's build_func and put it somewhere else.
272
273     print(srp.work)
274
275     # run through all queued up stage funcs for build
276     print("features:", n.header.features)
277     print("build funcs:", funcs)
278     for f in funcs:
279         # check for notes section class and create if needed
280         section = getattr(getattr(srp.features, f.name),
281                           "Notes"+f.name.capitalize(), False)
282         if section and not getattr(n, f.name, False):
283             print("creating notes section:", f.name)
284             setattr(n, f.name, section())
285
286         print("executing:", f)
287         if not srp.params.dry_run:
288             try:
289                 f.func()
290             except:
291                 print("ERROR: failed feature stage function:", f)
292                 raise
293
294     # now run through all queued up stage funcs for build_iter
295     #
296     # FIXME: multiprocessing
297     print("build_iter funcs:", iter_funcs)
298     for x in srp.work.build.manifest:
299         for f in iter_funcs:
300             # check for notes section class and create if needed
301             section = getattr(getattr(srp.features, f.name),
302                               "Notes"+f.name.capitalize(), False)
303             if section and not getattr(n, f.name, False):
304                 print("creating notes section:", f.name)
305                 setattr(n, f.name, section())
306
307             print("executing:", f, x)
308             if not srp.params.dry_run:
309                 try:
310                     f.func(x)
311                 except:
312                     print("ERROR: failed feature stage function:", f)
313                     raise
314
315     # create the toplevel brp archive
316     #
317     # FIXME: we should remove this file if we fail...
318     mach = platform.machine()
319     if not mach:
320         mach = "unknown"
321     pname = "{}.{}.brp".format(n.header.fullname, mach)
322     print("finalizing", pname)
323
324     if srp.params.dry_run:
325         # nothing more to do, since we didn't actually build anything to
326         # finalize into a brp...
327         return
328
329     # FIXME: compression should be configurable globally and also via
330     #        the command line when building.
331     #
332     if srp.config.default_compressor == "lzma":
333         import lzma
334         __brp = lzma.LZMAFile(pname, mode="w",
335                               preset=srp.config.compressors["lzma"])
336     elif srp.config.default_compressor == "bzip2":
337         import bz2
338         __brp = bz2.BZ2File(pname, mode="w",
339                             compresslevel=srp.config.compressors["bz2"])
340     elif srp.config.default_compressor == "gzip":
341         import gzip
342         __brp = gzip.GzipFile(pname, mode="w",
343                               compresslevel=srp.config.compressors["gzip"])
344     else:
345         # shouldn't really ever happen
346         raise Exception("invalid default compressor: {}".format(
347             srp.config.default_compressor))
348
349     brp = tarfile.open(fileobj=__brp, mode="w|")
350     sha = hashlib.new("sha1")
351
352     # populate the BLOB archive
353     #
354     # NOTE: This is where we actually add TarInfo objs and their associated
355     #       fobjs to the BLOB, then add the BLOB to the brp archive.
356     #
357     # NOTE: This is implemented using a temporary file as the fileobj for a
358     #       tarfile.  When the fobj is closed it's contents are lost, but
359     #       that's fine because we will have already added it to the toplevel
360     #       brp archive.
361     n.brp.time_blob_creation = time.time()
362     blob = srp.blob.BlobFile()
363     blob.manifest = srp.work.build.manifest
364     blob.fobj = tempfile.TemporaryFile()
365     blob.tofile()
366     n.brp.time_blob_creation = time.time() - n.brp.time_blob_creation
367     # add BLOB file to toplevel pkg archive
368     blob.fobj.seek(0)
369     brp.addfile(brp.gettarinfo(arcname="BLOB", fileobj=blob.fobj),
370                 fileobj=blob.fobj)
371     # rewind and generate a SHA entry
372     blob.fobj.seek(0)
373     sha.update(blob.fobj.read())
374     blob.fobj.close()
375
376     # add NOTES (pickled instance) to toplevel pkg archive (the brp)
377     n_fobj = tempfile.TemporaryFile()
378     # last chance toupdate time_total
379     n.brp.time_total = time.time() - n.brp.time_total
380     pickle.dump(n, n_fobj)
381     n_fobj.seek(0)
382     brp.addfile(brp.gettarinfo(arcname="NOTES", fileobj=n_fobj),
383                 fileobj=n_fobj)
384     # rewind and generate a SHA entry
385     n_fobj.seek(0)
386     sha.update(n_fobj.read())
387     n_fobj.close()
388
389     # create the SHA file and add it to the pkg
390     with tempfile.TemporaryFile() as f:
391         f.write(sha.hexdigest().encode())
392         f.seek(0)
393         brp.addfile(brp.gettarinfo(arcname="SHA", fileobj=f),
394                     fileobj=f)
395
396     # FIXME: all the files are still left in /tmp/srp-asdf...
397
398     # close the toplevel brp archive
399     brp.close()
400     __brp.close()
401
402
403 def install():
404     """Installs a package according to the RunTimeParameters instance
405     `srp.params'.
406
407     """
408     # create our work instance
409     srp.work.install = srp.features.InstallWork()
410
411     # get some local refs with shorter names
412     n = srp.work.install.notes
413     m = srp.work.install.manifest
414     funcs = srp.work.install.funcs
415     iter_funcs = srp.work.install.iter_funcs
416
417     # run through install funcs
418     print("features:", n.header.features)
419     print("install funcs:", funcs)
420     for f in funcs:
421         # check for notes section class and create if needed
422         section = getattr(getattr(srp.features, f.name),
423                           "Notes"+f.name.capitalize(), False)
424         if section and not getattr(n, f.name, False):
425             print("creating notes section:", f.name)
426             setattr(n, f.name, section())
427
428         print("executing:", f)
429         if not srp.params.dry_run:
430             try:
431                 f.func()
432             except:
433                 print("ERROR: failed feature stage function:", f)
434                 raise
435
436     # now run through all queued up stage funcs for install_iter
437     #
438     # FIXME: multiprocessing
439     print("install_iter funcs:", iter_funcs)
440     for x in m:
441         for f in iter_funcs:
442             # check for notes section class and create if needed
443             section = getattr(getattr(srp.features, f.name),
444                               "Notes"+f.name.capitalize(), False)
445             if section and not getattr(n, f.name, False):
446                 print("creating notes section:", f.name)
447                 setattr(n, f.name, section())
448
449             print("executing:", f, x)
450             if not srp.params.dry_run:
451                 try:
452                     f.func(x)
453                 except:
454                     print("ERROR: failed feature stage function:", f)
455                     raise
456
457     # commit NOTES to disk in srp db
458     #
459     # NOTE: We need to refresh our copy of n because feature funcs may have
460     #       modified the copy in work[].
461     #
462     n = srp.work.install.notes
463
464     # commit MANIFEST to disk in srp db
465     #
466     # NOTE: We need to refresh our copy because feature funcs may have
467     #       modified it
468     #
469     m = srp.work.install.manifest
470
471     # register w/ srp db
472     inst = srp.db.InstalledPackage(n, m)
473     srp.db.register(inst)
474
475     # commit db to disk
476     #
477     # FIXME: is there a better place for this?
478     if not srp.params.dry_run:
479         srp.db.commit()
480
481
482 # FIXME: Need to document these query type and criteria ramblings
483 #        somewhere user-visible...
484 #
485 # -q type[,type,...],criteria[,criteria]
486 #
487 # valid types:
488 #   - name (package name w/ version)
489 #   - info (summary)
490 #   - files (filenames)
491 #   - stats (stats for each file)
492 #   - size (total size of installed package)
493 #   - raw (super debug all)
494 #
495 # valid criteria:
496 #   - pkg (glob pkgname or path to brp)
497 #   - file (glob name of installed file)
498 #   - date_installed (-/+ for before/after)
499 #   - date_built (-/+ for before/after)
500 #   - size (-/+ for smaller/larger)
501 #   - grep (find string in info)
502 #   - built_by (glob builder name)
503 #   - built_on (glob built on host)
504 #
505 #
506 # What package installed the specified file:
507 #   srp -q name,file=/usr/lib/libcrust.so
508 #
509 # Show description of installed package:
510 #   srp -q info,pkg=srp-example
511 #
512 # List all files installed by package
513 #   srp -q files,pkg=srp-example
514 #
515 # List info,files for package on disk
516 #   srp -q info,files,pkg=./foo.brp
517 #
518 # List packages installed after specified date:
519 #   srp -q name,date_installed=2015-11-01+
520 #
521 #   srp -q name,date_built=2015-11-01+
522 #
523 #   srp -q name,size=1M+
524 #
525 #   srp -q name,built_by=mike
526 #
527 # Search through descriptions for any packages that match a pattern:
528 #   srp -q name,grep="tools for flabbergasting"
529 #
530 # Everything, and I mean everything, about a package:
531 #   srp -q raw,pkg=srp-example
532 #
533 def query():
534     """Performs a query according to the RunTimeParamters instance
535     `srp.params'.
536
537     FIXME: This mode is really different from the others... it doesn't
538            actually correlate with any stages defined by the Features API
539            and it doesn't really need a QueryWork object...
540
541     FIXME: I wonder if we should add a way for Features to define new
542            query types or criteria?  I've already got things plubmed into
543            feature_struct to add output to `info' queries... but I do have
544            size listed as a potention criteria in my ramblings
545            above... and size is defined via the Features API...
546
547     """
548     matches = []
549     for k in srp.params.query.criteria:
550         v = srp.params.query.criteria[k]
551         print("k={}, v={}".format(k, v))
552         if k == "pkg":
553             # glob pkgname or path to brp
554             matches.extend(query_pkg(v))
555         elif k == "file":
556             # glob name of installed file
557             matches.extend(query_file(v))
558         else:
559             raise Exception("Unsupported criteria '{}'".format(k))
560
561     print("fetching for all matches: {}".format(srp.params.query.types))
562     for m in matches:
563         for t in srp.params.query.types:
564             if t == "name":
565                 print(format_results_name(m))
566             elif t == "info":
567                 print(format_results_info(m))
568             elif t == "files":
569                 print(format_results_files(m))
570             elif t == "stats":
571                 print(format_results_stats(m))
572             elif t == "raw":
573                 print(format_results_raw(m))
574             else:
575                 raise Exception("Unsupported query type '{}'".format(t))
576
577 # FIXME: we should put all the pre-defined query_type and format_results
578 #        funcs somewhere else and dynamically extend them via the Features
579 #        API.
580 #
581 def query_pkg(name):
582     if os.path.exists(name):
583         # query package file on disk
584         #
585         # FIXME: shouldn't there be a helper func for basic brp-on-disk
586         #        access?
587         #
588         with tarfile.open(name) as p:
589             n_fobj = p.extractfile("NOTES")
590             n = pickle.load(n_fobj)
591             blob_fobj = p.extractfile("BLOB")
592             blob = srp.blob.BlobFile.fromfile(fobj=blob_fobj)
593             m = blob.manifest
594
595         return [srp.db.InstalledPackage(n, m)]
596
597     else:
598         # query installed package via db
599         return srp.db.lookup_by_name(name)
600
601
602 def format_results_name(p):
603     return "-".join((p.notes.header.name,
604                      p.notes.header.version,
605                      p.notes.header.pkg_rev))
606
607
608 def format_results_info(p):
609     # FIXME: make this a nice multi-collumn summary of the NOTES file,
610     #        excluding build_script, perms, etc
611     #
612     # FIXME: wrap text according to terminal size for description
613     #
614     info = []
615     info.append("Package: {}".format(format_results_name(p)))
616     info.append("Description: {}".format(p.notes.header.description))
617     
618     for f in srp.features.registered_features:
619         info_func = srp.features.registered_features[f].info
620         if info_func:
621             info.append(info_func(p))
622
623     return "\n".join(info)
624
625
626 def format_results_files(p):
627     return "\n".join(p.manifest.sortedkeys)
628
629
630 def format_tinfo(t):
631     fmt = "{mode} {uid:8} {gid:8} {size:>8} {date} {name}{link}"
632     mode = stat.filemode(t.mode)
633     uid = t.uname or t.uid
634     gid = t.gname or t.gid
635     if t.ischr() or t.isblk():
636         size = "{},{}".format(t.devmajor, t.devminor)
637     else:
638         size = t.size
639     date = "{}-{:02}-{:02} {:02}:{:02}:{:02}".format(
640         *time.localtime(t.mtime)[:6])
641     name = t.name + ("/" if t.isdir() else "")
642     if t.issym():
643         link = " -> " + t.linkname
644     elif t.islnk():
645         link = " link to " + t.linkname
646     else:
647         link = ""
648     return fmt.format(**locals())
649
650
651 def format_results_stats(p):
652     retval = []
653     for f in p.manifest:
654         tinfo = p.manifest[f]["tinfo"]
655         retval.append(format_tinfo(tinfo))
656     return "\n".join(retval)
657
658
659 def format_results_raw(p):
660     return "{}\n{}".format(
661         p.notes,
662         p.manifest)