"""Version bumps, self-backups, and release bookkeeping.""" import hashlib import os import sqlite3 import time from datetime import datetime, timezone from tai_pkg.consts import Consts from tai_pkg.text import Text __all__ = ["Release"] class Release: """Release.""" @staticmethod def next_version(current, part): major, minor, patch = (int(item) for item in current.split(".")) if part == "major": return "%d.0.0" % (major + 1) if part == "minor": return "%d.%d.0" % (major, minor + 1) if part == "patch": return "%d.%d.%d" % (major, minor, patch + 1) raise ValueError("part must be major, minor, or patch") @staticmethod def atomic_write(path, content): tmp = "%s.tmp-%d" % (path, os.getpid()) with open(tmp, "w", encoding="utf-8") as handle: handle.write(content) handle.flush() os.fsync(handle.fileno()) os.replace(tmp, path) @staticmethod def sealed_write(path, content): Release.atomic_write(path, content) try: os.chmod(path, 0o600) except OSError: pass @staticmethod def quarantine(path): try: if os.path.exists(path): os.replace(path, "%s.corrupt-%d" % (path, int(time.time()))) except OSError: pass @staticmethod def release_bump(script_file, part): with open(script_file, "r", encoding="utf-8") as handle: text = handle.read() match = Consts.VERSION_RE.search(text) if match is None: raise ValueError("no VERSION line in %s" % script_file) old = ".".join(match.groups()) new = Release.next_version(old, part) matched = text[match.start():match.end()] indent = matched[:len(matched) - len(matched.lstrip())] Release.atomic_write(script_file, text[:match.start()] + indent + "VERSION = \"%s\"" % new + text[match.end():]) return old, new @staticmethod def backups_dir(home): return os.path.join(home, "backups") @staticmethod def prune_backups(folder): try: names = sorted((entry for entry in os.listdir(folder) if entry.startswith("tai-") and entry.endswith(".py")), key=lambda entry: os.path.getmtime(os.path.join(folder, entry))) except OSError: return 0 pruned = 0 while len(names) > Consts.BACKUP_KEEP: try: os.remove(os.path.join(folder, names.pop(0))) pruned += 1 except OSError: break return pruned @staticmethod def snapshot_self(script_file, folder, version): with open(script_file, "rb") as handle: data = handle.read() digest = hashlib.sha256(data).hexdigest()[:8] stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") name = "tai-%s-%s-%s.py" % (version, stamp, digest) os.makedirs(folder, exist_ok=True) dest = os.path.join(folder, name) with open(dest, "wb") as handle: handle.write(data) Release.prune_backups(folder) return dest, digest @staticmethod def backup_has_digest(folder, digest): try: return any(entry.endswith("-%s.py" % digest) for entry in os.listdir(folder)) except OSError: return False @staticmethod def ensure_self_backup(config, store, script_file=None, actor="main"): script_file = script_file or os.path.abspath(Consts.TAI_SCRIPT) folder = Release.backups_dir(config.home) with open(script_file, "rb") as handle: digest = hashlib.sha256(handle.read()).hexdigest()[:8] if Release.backup_has_digest(folder, digest): return None dest, _digest = Release.snapshot_self(script_file, folder, Consts.VERSION) try: store.audit_event(actor, "snapshot", script_file, "backed up as %s" % os.path.basename(dest), tags=["snapshot", "v" + Consts.VERSION]) text, override = Text.read_capped(script_file, Consts.FILE_RECORD_MAX) if text is not None: store.upsert_file_record(script_file, text, override) except (sqlite3.Error, OSError): pass return dest @staticmethod def do_release(store, script_file, folder, part, message, actor="main"): if not message or not message.strip(): raise ValueError("release message is required") old, new = Release.release_bump(script_file, part) dest, _digest = Release.snapshot_self(script_file, folder, new) row_id = store.audit_event(actor, "release", script_file, message.strip()[:500], old=old, new=new, tags=["release", "v" + new]) return old, new, dest, row_id