Some checks failed
lint / docker (push) Has been cancelled
- Move all backup_*.py files to libtisbackup/drivers/ subdirectory - Move XenAPI.py and copy_vm_xcp.py to drivers/ (driver-specific) - Create drivers/__init__.py with automatic driver imports - Update tisbackup.py imports to use new structure - Add pyvmomi>=8.0.0 as mandatory dependency - Sync requirements.txt with pyproject.toml dependencies - Add pylint>=3.0.0 and pytest-cov>=6.0.0 to dev dependencies - Configure pylint and coverage tools in pyproject.toml - Add conventional commits guidelines to CLAUDE.md - Enhance .gitignore with comprehensive patterns for Python, IDEs, testing, and secrets - Update CLAUDE.md documentation with new structure and tooling Breaking Changes: - Drivers must now be imported from libtisbackup.drivers instead of libtisbackup - All backup driver files relocated to drivers/ subdirectory 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
262 lines
8.2 KiB
Python
262 lines
8.2 KiB
Python
#!/usr/bin/python3
|
|
# -*- coding: utf-8 -*-
|
|
# -----------------------------------------------------------------------
|
|
# This file is part of TISBackup
|
|
#
|
|
# TISBackup is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# TISBackup is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
|
#
|
|
# -----------------------------------------------------------------------
|
|
|
|
"""Database management for backup statistics and history."""
|
|
|
|
import logging
|
|
import os
|
|
import sqlite3
|
|
|
|
from .utils import (
|
|
convert_bytes,
|
|
datetime2isodate,
|
|
hours_minutes,
|
|
html_table,
|
|
isodate2datetime,
|
|
pp,
|
|
splitThousands,
|
|
time2display,
|
|
)
|
|
|
|
|
|
class BackupStat:
|
|
"""Manages SQLite database for backup statistics and history."""
|
|
|
|
dbpath = ""
|
|
db = None
|
|
logger = logging.getLogger("tisbackup")
|
|
|
|
def __init__(self, dbpath):
|
|
self.dbpath = dbpath
|
|
if not os.path.isfile(self.dbpath):
|
|
self.db = sqlite3.connect(self.dbpath)
|
|
self.initdb()
|
|
else:
|
|
self.db = sqlite3.connect(self.dbpath, check_same_thread=False)
|
|
if "'TYPE'" not in str(self.db.execute("select * from stats").description):
|
|
self.updatedb()
|
|
|
|
def updatedb(self):
|
|
"""Update database schema to add TYPE column if missing."""
|
|
self.logger.debug("Update stat database")
|
|
self.db.execute("alter table stats add column TYPE TEXT;")
|
|
self.db.execute("update stats set TYPE='BACKUP';")
|
|
self.db.commit()
|
|
|
|
def initdb(self):
|
|
"""Initialize database schema."""
|
|
assert isinstance(self.db, sqlite3.Connection)
|
|
self.logger.debug("Initialize stat database")
|
|
self.db.execute("""
|
|
create table stats (
|
|
backup_name TEXT,
|
|
server_name TEXT,
|
|
description TEXT,
|
|
backup_start TEXT,
|
|
backup_end TEXT,
|
|
backup_duration NUMERIC,
|
|
total_files_count INT,
|
|
written_files_count INT,
|
|
total_bytes INT,
|
|
written_bytes INT,
|
|
status TEXT,
|
|
log TEXT,
|
|
backup_location TEXT,
|
|
TYPE TEXT)""")
|
|
self.db.execute("""
|
|
create index idx_stats_backup_name on stats(backup_name);""")
|
|
self.db.execute("""
|
|
create index idx_stats_backup_location on stats(backup_location);""")
|
|
self.db.execute("""
|
|
CREATE INDEX idx_stats_backup_name_start on stats(backup_name,backup_start);""")
|
|
self.db.commit()
|
|
|
|
def start(self, backup_name, server_name, TYPE, description="", backup_location=None):
|
|
"""Add in stat DB a record for the newly running backup"""
|
|
return self.add(
|
|
backup_name=backup_name,
|
|
server_name=server_name,
|
|
description=description,
|
|
backup_start=datetime2isodate(),
|
|
status="Running",
|
|
TYPE=TYPE,
|
|
)
|
|
|
|
def finish(
|
|
self,
|
|
rowid,
|
|
total_files_count=None,
|
|
written_files_count=None,
|
|
total_bytes=None,
|
|
written_bytes=None,
|
|
log=None,
|
|
status="OK",
|
|
backup_end=None,
|
|
backup_duration=None,
|
|
backup_location=None,
|
|
):
|
|
"""Update record in stat DB for the finished backup"""
|
|
if not backup_end:
|
|
backup_end = datetime2isodate()
|
|
if backup_duration is None:
|
|
try:
|
|
# get duration using start of backup datetime
|
|
backup_duration = (
|
|
isodate2datetime(backup_end)
|
|
- isodate2datetime(self.query("select backup_start from stats where rowid=?", (rowid,))[0]["backup_start"])
|
|
).seconds / 3600.0
|
|
except:
|
|
backup_duration = None
|
|
|
|
# update stat record
|
|
self.db.execute(
|
|
"""\
|
|
update stats set
|
|
total_files_count=?,written_files_count=?,total_bytes=?,written_bytes=?,log=?,status=?,backup_end=?,backup_duration=?,backup_location=?
|
|
where
|
|
rowid = ?
|
|
""",
|
|
(
|
|
total_files_count,
|
|
written_files_count,
|
|
total_bytes,
|
|
written_bytes,
|
|
log,
|
|
status,
|
|
backup_end,
|
|
backup_duration,
|
|
backup_location,
|
|
rowid,
|
|
),
|
|
)
|
|
self.db.commit()
|
|
|
|
def add(
|
|
self,
|
|
backup_name="",
|
|
server_name="",
|
|
description="",
|
|
backup_start=None,
|
|
backup_end=None,
|
|
backup_duration=None,
|
|
total_files_count=None,
|
|
written_files_count=None,
|
|
total_bytes=None,
|
|
written_bytes=None,
|
|
status="draft",
|
|
log="",
|
|
TYPE="",
|
|
backup_location=None,
|
|
):
|
|
"""Add a new backup record to the database."""
|
|
if not backup_start:
|
|
backup_start = datetime2isodate()
|
|
if not backup_end:
|
|
backup_end = datetime2isodate()
|
|
|
|
cur = self.db.execute(
|
|
"""\
|
|
insert into stats (
|
|
backup_name,
|
|
server_name,
|
|
description,
|
|
backup_start,
|
|
backup_end,
|
|
backup_duration,
|
|
total_files_count,
|
|
written_files_count,
|
|
total_bytes,
|
|
written_bytes,
|
|
status,
|
|
log,
|
|
backup_location,
|
|
TYPE) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
""",
|
|
(
|
|
backup_name,
|
|
server_name,
|
|
description,
|
|
backup_start,
|
|
backup_end,
|
|
backup_duration,
|
|
total_files_count,
|
|
written_files_count,
|
|
total_bytes,
|
|
written_bytes,
|
|
status,
|
|
log,
|
|
backup_location,
|
|
TYPE,
|
|
),
|
|
)
|
|
|
|
self.db.commit()
|
|
return cur.lastrowid
|
|
|
|
def query(self, query, args=(), one=False):
|
|
"""
|
|
execute la requete query sur la db et renvoie un tableau de dictionnaires
|
|
"""
|
|
cur = self.db.execute(query, args)
|
|
rv = [dict((cur.description[idx][0], value) for idx, value in enumerate(row)) for row in cur.fetchall()]
|
|
return (rv[0] if rv else None) if one else rv
|
|
|
|
def last_backups(self, backup_name, count=30):
|
|
"""Display last N backups for a given backup_name."""
|
|
if backup_name:
|
|
cur = self.db.execute("select * from stats where backup_name=? order by backup_end desc limit ?", (backup_name, count))
|
|
else:
|
|
cur = self.db.execute("select * from stats order by backup_end desc limit ?", (count,))
|
|
|
|
def fcb(fieldname, value):
|
|
if fieldname in ("backup_start", "backup_end"):
|
|
return time2display(isodate2datetime(value))
|
|
elif "bytes" in fieldname:
|
|
return convert_bytes(value)
|
|
elif "count" in fieldname:
|
|
return splitThousands(value, " ", ".")
|
|
elif "backup_duration" in fieldname:
|
|
return hours_minutes(value)
|
|
else:
|
|
return value
|
|
|
|
# for r in self.query('select * from stats where backup_name=? order by backup_end desc limit ?',(backup_name,count)):
|
|
print((pp(cur, None, 1, fcb)))
|
|
|
|
def fcb(self, fields, fieldname, value):
|
|
"""Format callback for HTML table display."""
|
|
if fieldname in ("backup_start", "backup_end"):
|
|
return time2display(isodate2datetime(value))
|
|
elif "bytes" in fieldname:
|
|
return convert_bytes(value)
|
|
elif "count" in fieldname:
|
|
return splitThousands(value, " ", ".")
|
|
elif "backup_duration" in fieldname:
|
|
return hours_minutes(value)
|
|
else:
|
|
return value
|
|
|
|
def as_html(self, cur):
|
|
"""Convert cursor to HTML table."""
|
|
if cur:
|
|
return html_table(cur, self.fcb)
|
|
else:
|
|
return html_table(self.db.execute("select * from stats order by backup_start asc"), self.fcb)
|