diff --git a/src/nancy/cli/__init__.py b/src/nancy/cli/__init__.py index 6df259b..054bf3e 100644 --- a/src/nancy/cli/__init__.py +++ b/src/nancy/cli/__init__.py @@ -3,9 +3,7 @@ from loguru import logger from ..version import __version__ -from . import diff -from . import record -from . import show +from . import check, diff, record, show from typing import Optional @@ -50,6 +48,7 @@ def main(log_level: str) -> None: logger.add(sys.stderr, level=log_level) +main.add_command(check.check_cli, name="check") main.add_command(diff.status, name="status") main.add_command(record.record_cli, name="record") main.add_command(show.show_cli, name="show") diff --git a/src/nancy/cli/check.py b/src/nancy/cli/check.py new file mode 100644 index 0000000..6bfc018 --- /dev/null +++ b/src/nancy/cli/check.py @@ -0,0 +1,143 @@ +import click +from loguru import logger + +from .. import fs, store +from .diff import print_diff + +from datetime import datetime +import os +import sys +from typing import Any, List, Optional, Union +import warnings + + +@logger.catch +def check( + store_path: Optional[Union[str, "os.PathLike[Any]"]] = None, + show_hashes: bool = False, + use_color: bool = True, +) -> None: + """Unwrapped check command""" + start_time = datetime.now() + + if store_path is None: + curdir = os.path.realpath(os.getcwd()) + logger.info("Looking for store in {}", curdir) + store_path = store.find_store(curdir) + if store_path is None: # If no store found, assume we're creating here + logger.error( + "Could not find nancy.db in any directory containing {}", curdir + ) + sys.exit(1) + logger.info("Found store at {}", store_path) + s = store.Store(store_path) + + cur = s.conn.cursor() + + def columns(table: str, columns: List[str]): + cur.execute(f"SELECT {','.join(columns)} FROM {table}") + return cur.fetchall() + + def oneresult(qry: str): + """Run a query that returns just one thing""" + cur.execute(qry) + (c,) = cur.fetchone() + return c + + def count(table: str): + return oneresult(f"SELECT COUNT(*) FROM {table}") + + local_uuid = oneresult('SELECT value FROM local_metadata WHERE key == "store_uuid"') + print(f'Local store has UUID "{local_uuid}"') + + print(f"Stores ({count('store')}):") + for (storeid,) in columns("store", ["uuid"]): + print(f" {storeid}") + + print(f"Machines ({count('machine')}):") + for (sha256, name, machineid) in columns( + "machine", ["sha256", "hostname", "machine_id"] + ): + print(f" {name:15} (sha256: {sha256}, machine-id: {machineid})") + + print(f"Users ({count('user')}):") + cur.execute("SELECT user.sha256,fullname,username,hostname FROM user JOIN machine") + for sha256, full, uname, hostname in cur.fetchall(): + print(f' {uname + "@" + hostname:20} (fullname: {full}, sha256: {sha256}') + + print(f"Programs ({count('program')}):") + cur.execute( + """ + SELECT program.uuid,start_time,end_time,username,hostname,name,message + FROM program + JOIN environment JOIN user JOIN machine + """ + ) + for progid, starttime, endtime, uname, host, progname, message in cur.fetchall(): + starttime = datetime.fromtimestamp(starttime) + endtime = datetime.fromtimestamp(endtime) + print( + f" {progname} " + f"(message:{message}, " + f"user:{uname}@{host}, " + f"start: {starttime} {starttime.tzinfo}, " + f"elapsed: {endtime - starttime})" + ) + + print(f"Environments: {count('environment')}") + print(f"Packages: {count('package')}") + print(f"Modules: {count('module')}") + print(f"Functions: {count('func')}") + print(f"Tasks: {count('task')}") + print(f"Files/Directories: {count('filedir')}") + print(f"File/Directory versions: {count('filedir_version')}") + + # then find a listing covering all the expected paths + print("Checking for changes to filesystem. This may take a while...") + diff = s.diff() + if diff.A is None: + logger.warning( + f"No recorded entries in {store_path}. Have you run `nancy record` yet?" + ) + + if diff.is_modified(): + logger.warning("Files have been modified:") + print_diff(diff) + else: + print("All files/directories are consistent with recorded versions.") + + +@click.command() +@click.option( + "-H", + "--show-hashes", + is_flag=True, + help="If given, prepend each line in the diff with the new file hash (SHA256).", +) +@click.option( + "--no-color", + is_flag=True, + help="If given, do not print any color output.", +) +@click.option( + "-s", + "--store", + type=str, + default=None, + help="Top-level of store. If omitted, use closest common parent directory " + "of given paths. If given the path to a non-store directory, a new " + "store is initialized there.", +) +def check_cli( + show_hashes: bool, + no_color: bool, + store: str, +) -> None: + """ + Check the store for altered files and other problems. + """ + check( + show_hashes=show_hashes, + use_color=not no_color, + store_path=store, + ) diff --git a/src/nancy/fs.py b/src/nancy/fs.py index ceb431c..c3f5c01 100644 --- a/src/nancy/fs.py +++ b/src/nancy/fs.py @@ -432,6 +432,15 @@ class FSDiff: and Alatest.deleted == Blatest.deleted ) + def is_modified(self): + """Return True only if there is a difference between A and B.""" + if len(self.modified_children) > 0: + return True + if self.A is None or self.B is None: + return True + else: + return not self.__class__.compare(self.A, self.B) + def filename(self) -> str: if self.A is not None: return self.A.filename