2023-07-20 15:24:26 +00:00
|
|
|
"""
|
|
|
|
This module contains the controller class for backups.
|
|
|
|
"""
|
2023-04-10 13:22:33 +00:00
|
|
|
from datetime import datetime, timedelta
|
2023-07-26 11:54:17 +00:00
|
|
|
import os
|
2023-07-18 17:15:22 +00:00
|
|
|
from os import statvfs
|
2023-06-26 18:07:47 +00:00
|
|
|
from typing import List, Optional
|
2023-02-17 15:55:19 +00:00
|
|
|
|
2023-06-16 15:09:39 +00:00
|
|
|
from selfprivacy_api.utils import ReadUserData, WriteUserData
|
2023-02-08 14:05:25 +00:00
|
|
|
|
2023-07-20 14:11:02 +00:00
|
|
|
from selfprivacy_api.services import (
|
|
|
|
get_service_by_id,
|
|
|
|
get_all_services,
|
|
|
|
)
|
2023-07-18 17:15:22 +00:00
|
|
|
from selfprivacy_api.services.service import (
|
|
|
|
Service,
|
|
|
|
ServiceStatus,
|
|
|
|
StoppedService,
|
|
|
|
)
|
2023-02-22 14:45:11 +00:00
|
|
|
|
2023-07-07 10:50:59 +00:00
|
|
|
from selfprivacy_api.jobs import Jobs, JobStatus, Job
|
2023-06-26 18:07:47 +00:00
|
|
|
|
2023-06-23 09:40:10 +00:00
|
|
|
from selfprivacy_api.graphql.queries.providers import (
|
|
|
|
BackupProvider as BackupProviderEnum,
|
|
|
|
)
|
2023-08-28 17:02:45 +00:00
|
|
|
from selfprivacy_api.graphql.common_types.backup import (
|
|
|
|
RestoreStrategy,
|
|
|
|
BackupReason,
|
|
|
|
AutobackupQuotas,
|
|
|
|
)
|
|
|
|
from selfprivacy_api.backup.time import (
|
|
|
|
same_day,
|
|
|
|
same_month,
|
|
|
|
same_week,
|
|
|
|
same_year,
|
|
|
|
same_lifetime_of_the_universe,
|
|
|
|
)
|
2023-01-23 13:43:18 +00:00
|
|
|
|
2023-06-26 18:07:47 +00:00
|
|
|
from selfprivacy_api.models.backup.snapshot import Snapshot
|
|
|
|
|
2023-04-10 13:22:33 +00:00
|
|
|
from selfprivacy_api.backup.providers.provider import AbstractBackupProvider
|
|
|
|
from selfprivacy_api.backup.providers import get_provider
|
|
|
|
from selfprivacy_api.backup.storage import Storage
|
2023-06-07 15:05:58 +00:00
|
|
|
from selfprivacy_api.backup.jobs import (
|
|
|
|
get_backup_job,
|
|
|
|
add_backup_job,
|
|
|
|
get_restore_job,
|
|
|
|
add_restore_job,
|
|
|
|
)
|
2023-03-13 19:03:41 +00:00
|
|
|
|
2023-06-16 15:09:39 +00:00
|
|
|
DEFAULT_JSON_PROVIDER = {
|
|
|
|
"provider": "BACKBLAZE",
|
|
|
|
"accountId": "",
|
|
|
|
"accountKey": "",
|
|
|
|
"bucket": "",
|
|
|
|
}
|
|
|
|
|
2023-07-26 11:54:17 +00:00
|
|
|
BACKUP_PROVIDER_ENVS = {
|
|
|
|
"kind": "BACKUP_KIND",
|
|
|
|
"login": "BACKUP_LOGIN",
|
|
|
|
"key": "BACKUP_KEY",
|
|
|
|
"location": "BACKUP_LOCATION",
|
|
|
|
}
|
|
|
|
|
2023-03-10 14:14:41 +00:00
|
|
|
|
2023-07-12 16:43:26 +00:00
|
|
|
class NotDeadError(AssertionError):
|
2023-07-20 15:24:26 +00:00
|
|
|
"""
|
|
|
|
This error is raised when we try to back up a service that is not dead yet.
|
|
|
|
"""
|
2023-07-20 16:37:01 +00:00
|
|
|
|
2023-07-12 16:43:26 +00:00
|
|
|
def __init__(self, service: Service):
|
|
|
|
self.service_name = service.get_id()
|
2023-07-20 15:24:26 +00:00
|
|
|
super().__init__()
|
2023-07-12 16:43:26 +00:00
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
return f"""
|
2023-07-18 17:15:22 +00:00
|
|
|
Service {self.service_name} should be either stopped or dead from
|
|
|
|
an error before we back up.
|
|
|
|
Normally, this error is unreachable because we do try ensure this.
|
|
|
|
Apparently, not this time.
|
|
|
|
"""
|
2023-07-12 16:43:26 +00:00
|
|
|
|
|
|
|
|
2023-02-20 16:09:01 +00:00
|
|
|
class Backups:
|
2023-06-26 18:00:42 +00:00
|
|
|
"""A stateless controller class for backups"""
|
2023-02-08 14:18:45 +00:00
|
|
|
|
2023-07-18 17:15:22 +00:00
|
|
|
# Providers
|
2023-04-03 18:54:27 +00:00
|
|
|
|
2023-03-29 11:15:38 +00:00
|
|
|
@staticmethod
|
2023-07-19 12:59:29 +00:00
|
|
|
def provider() -> AbstractBackupProvider:
|
2023-07-20 15:24:26 +00:00
|
|
|
"""
|
|
|
|
Returns the current backup storage provider.
|
|
|
|
"""
|
2023-06-26 18:11:11 +00:00
|
|
|
return Backups._lookup_provider()
|
2023-03-29 11:15:38 +00:00
|
|
|
|
|
|
|
@staticmethod
|
2023-06-23 09:40:10 +00:00
|
|
|
def set_provider(
|
|
|
|
kind: BackupProviderEnum,
|
|
|
|
login: str,
|
|
|
|
key: str,
|
|
|
|
location: str,
|
|
|
|
repo_id: str = "",
|
2023-07-19 12:59:29 +00:00
|
|
|
) -> None:
|
2023-07-20 15:24:26 +00:00
|
|
|
"""
|
|
|
|
Sets the new configuration of the backup storage provider.
|
|
|
|
|
|
|
|
In case of `BackupProviderEnum.BACKBLAZE`, the `login` is the key ID,
|
|
|
|
the `key` is the key itself, and the `location` is the bucket name and
|
|
|
|
the `repo_id` is the bucket ID.
|
|
|
|
"""
|
2023-07-19 12:59:29 +00:00
|
|
|
provider: AbstractBackupProvider = Backups._construct_provider(
|
2023-06-23 09:40:10 +00:00
|
|
|
kind,
|
|
|
|
login,
|
|
|
|
key,
|
|
|
|
location,
|
|
|
|
repo_id,
|
|
|
|
)
|
2023-04-10 13:22:33 +00:00
|
|
|
Storage.store_provider(provider)
|
2023-02-20 13:51:06 +00:00
|
|
|
|
2023-06-26 18:21:50 +00:00
|
|
|
@staticmethod
|
2023-07-19 12:59:29 +00:00
|
|
|
def reset(reset_json=True) -> None:
|
2023-07-20 15:24:26 +00:00
|
|
|
"""
|
|
|
|
Deletes all the data about the backup storage provider.
|
|
|
|
"""
|
2023-06-26 18:21:50 +00:00
|
|
|
Storage.reset()
|
|
|
|
if reset_json:
|
|
|
|
try:
|
|
|
|
Backups._reset_provider_json()
|
|
|
|
except FileNotFoundError:
|
|
|
|
# if there is no userdata file, we do not need to reset it
|
|
|
|
pass
|
|
|
|
|
2023-03-29 11:15:38 +00:00
|
|
|
@staticmethod
|
2023-06-26 18:11:11 +00:00
|
|
|
def _lookup_provider() -> AbstractBackupProvider:
|
2023-06-26 18:20:22 +00:00
|
|
|
redis_provider = Backups._load_provider_redis()
|
2023-02-08 14:57:34 +00:00
|
|
|
if redis_provider is not None:
|
2023-03-10 14:14:41 +00:00
|
|
|
return redis_provider
|
2023-02-08 14:57:34 +00:00
|
|
|
|
2023-06-19 11:09:10 +00:00
|
|
|
try:
|
2023-06-26 18:20:22 +00:00
|
|
|
json_provider = Backups._load_provider_json()
|
2023-06-19 11:09:10 +00:00
|
|
|
except FileNotFoundError:
|
|
|
|
json_provider = None
|
|
|
|
|
2023-02-08 14:57:34 +00:00
|
|
|
if json_provider is not None:
|
2023-04-10 13:22:33 +00:00
|
|
|
Storage.store_provider(json_provider)
|
2023-03-10 14:14:41 +00:00
|
|
|
return json_provider
|
2023-02-08 14:57:34 +00:00
|
|
|
|
2023-06-26 18:14:15 +00:00
|
|
|
none_provider = Backups._construct_provider(
|
2023-06-23 09:40:10 +00:00
|
|
|
BackupProviderEnum.NONE, login="", key="", location=""
|
2023-06-19 11:09:10 +00:00
|
|
|
)
|
|
|
|
Storage.store_provider(none_provider)
|
|
|
|
return none_provider
|
2023-02-08 14:57:34 +00:00
|
|
|
|
2023-07-26 11:54:17 +00:00
|
|
|
@staticmethod
|
|
|
|
def set_provider_from_envs():
|
|
|
|
for env in BACKUP_PROVIDER_ENVS.values():
|
|
|
|
if env not in os.environ.keys():
|
|
|
|
raise ValueError(
|
|
|
|
f"Cannot set backup provider from envs, there is no {env} set"
|
|
|
|
)
|
|
|
|
|
|
|
|
kind_str = os.environ[BACKUP_PROVIDER_ENVS["kind"]]
|
|
|
|
kind_enum = BackupProviderEnum[kind_str]
|
|
|
|
provider = Backups._construct_provider(
|
|
|
|
kind=kind_enum,
|
|
|
|
login=os.environ[BACKUP_PROVIDER_ENVS["login"]],
|
|
|
|
key=os.environ[BACKUP_PROVIDER_ENVS["key"]],
|
|
|
|
location=os.environ[BACKUP_PROVIDER_ENVS["location"]],
|
|
|
|
)
|
|
|
|
Storage.store_provider(provider)
|
|
|
|
|
2023-06-26 18:00:42 +00:00
|
|
|
@staticmethod
|
2023-06-26 18:14:15 +00:00
|
|
|
def _construct_provider(
|
2023-06-26 18:00:42 +00:00
|
|
|
kind: BackupProviderEnum,
|
|
|
|
login: str,
|
|
|
|
key: str,
|
|
|
|
location: str,
|
|
|
|
repo_id: str = "",
|
|
|
|
) -> AbstractBackupProvider:
|
|
|
|
provider_class = get_provider(kind)
|
|
|
|
|
|
|
|
return provider_class(
|
|
|
|
login=login,
|
|
|
|
key=key,
|
|
|
|
location=location,
|
|
|
|
repo_id=repo_id,
|
|
|
|
)
|
|
|
|
|
|
|
|
@staticmethod
|
2023-06-26 18:20:22 +00:00
|
|
|
def _load_provider_redis() -> Optional[AbstractBackupProvider]:
|
2023-06-26 18:00:42 +00:00
|
|
|
provider_model = Storage.load_provider()
|
|
|
|
if provider_model is None:
|
|
|
|
return None
|
2023-06-26 18:14:15 +00:00
|
|
|
return Backups._construct_provider(
|
2023-06-26 18:00:42 +00:00
|
|
|
BackupProviderEnum[provider_model.kind],
|
|
|
|
provider_model.login,
|
|
|
|
provider_model.key,
|
|
|
|
provider_model.location,
|
|
|
|
provider_model.repo_id,
|
|
|
|
)
|
|
|
|
|
2023-02-08 14:57:34 +00:00
|
|
|
@staticmethod
|
2023-06-26 18:20:22 +00:00
|
|
|
def _load_provider_json() -> Optional[AbstractBackupProvider]:
|
2023-03-10 14:14:41 +00:00
|
|
|
with ReadUserData() as user_data:
|
2023-06-19 11:09:10 +00:00
|
|
|
provider_dict = {
|
|
|
|
"provider": "",
|
|
|
|
"accountId": "",
|
|
|
|
"accountKey": "",
|
|
|
|
"bucket": "",
|
|
|
|
}
|
2023-03-10 14:14:41 +00:00
|
|
|
|
|
|
|
if "backup" not in user_data.keys():
|
|
|
|
if "backblaze" in user_data.keys():
|
2023-06-19 11:09:10 +00:00
|
|
|
provider_dict.update(user_data["backblaze"])
|
|
|
|
provider_dict["provider"] = "BACKBLAZE"
|
|
|
|
return None
|
|
|
|
else:
|
|
|
|
provider_dict.update(user_data["backup"])
|
|
|
|
|
|
|
|
if provider_dict == DEFAULT_JSON_PROVIDER:
|
2023-03-10 14:14:41 +00:00
|
|
|
return None
|
2023-06-23 09:40:10 +00:00
|
|
|
try:
|
2023-06-26 18:14:15 +00:00
|
|
|
return Backups._construct_provider(
|
2023-06-23 09:40:10 +00:00
|
|
|
kind=BackupProviderEnum[provider_dict["provider"]],
|
|
|
|
login=provider_dict["accountId"],
|
|
|
|
key=provider_dict["accountKey"],
|
|
|
|
location=provider_dict["bucket"],
|
|
|
|
)
|
|
|
|
except KeyError:
|
|
|
|
return None
|
2023-03-10 14:14:41 +00:00
|
|
|
|
2023-06-23 09:40:10 +00:00
|
|
|
@staticmethod
|
2023-06-26 18:20:22 +00:00
|
|
|
def _reset_provider_json() -> None:
|
2023-06-16 15:09:39 +00:00
|
|
|
with WriteUserData() as user_data:
|
|
|
|
if "backblaze" in user_data.keys():
|
|
|
|
del user_data["backblaze"]
|
|
|
|
|
|
|
|
user_data["backup"] = DEFAULT_JSON_PROVIDER
|
|
|
|
|
2023-07-18 17:15:22 +00:00
|
|
|
# Init
|
2023-06-26 18:30:31 +00:00
|
|
|
|
|
|
|
@staticmethod
|
2023-07-19 12:59:29 +00:00
|
|
|
def init_repo() -> None:
|
2023-07-20 15:24:26 +00:00
|
|
|
"""
|
|
|
|
Initializes the backup repository. This is required once per repo.
|
|
|
|
"""
|
2023-06-26 18:30:31 +00:00
|
|
|
Backups.provider().backupper.init()
|
|
|
|
Storage.mark_as_init()
|
|
|
|
|
2023-07-26 16:45:08 +00:00
|
|
|
@staticmethod
|
|
|
|
def erase_repo() -> None:
|
|
|
|
"""
|
|
|
|
Completely empties the remote
|
|
|
|
"""
|
|
|
|
Backups.provider().backupper.erase_repo()
|
|
|
|
Storage.mark_as_uninitted()
|
|
|
|
|
2023-06-26 18:30:31 +00:00
|
|
|
@staticmethod
|
|
|
|
def is_initted() -> bool:
|
2023-07-20 15:24:26 +00:00
|
|
|
"""
|
|
|
|
Returns whether the backup repository is initialized or not.
|
|
|
|
If it is not initialized, we cannot back up and probably should
|
|
|
|
call `init_repo` first.
|
|
|
|
"""
|
2023-06-26 18:30:31 +00:00
|
|
|
if Storage.has_init_mark():
|
|
|
|
return True
|
|
|
|
|
|
|
|
initted = Backups.provider().backupper.is_initted()
|
|
|
|
if initted:
|
|
|
|
Storage.mark_as_init()
|
|
|
|
return True
|
|
|
|
|
|
|
|
return False
|
2023-06-26 18:00:42 +00:00
|
|
|
|
2023-07-18 17:15:22 +00:00
|
|
|
# Backup
|
2023-04-10 13:22:33 +00:00
|
|
|
|
2023-03-29 11:15:38 +00:00
|
|
|
@staticmethod
|
2023-08-21 11:11:56 +00:00
|
|
|
def back_up(
|
|
|
|
service: Service, reason: BackupReason = BackupReason.EXPLICIT
|
|
|
|
) -> Snapshot:
|
2023-04-07 15:41:02 +00:00
|
|
|
"""The top-level function to back up a service"""
|
2023-04-14 11:20:03 +00:00
|
|
|
folders = service.get_folders()
|
2023-08-21 11:11:56 +00:00
|
|
|
service_name = service.get_id()
|
2023-02-08 15:27:49 +00:00
|
|
|
|
2023-04-24 16:37:07 +00:00
|
|
|
job = get_backup_job(service)
|
|
|
|
if job is None:
|
|
|
|
job = add_backup_job(service)
|
|
|
|
Jobs.update(job, status=JobStatus.RUNNING)
|
2023-04-24 16:15:12 +00:00
|
|
|
|
2023-05-08 10:55:22 +00:00
|
|
|
try:
|
2023-07-20 14:11:02 +00:00
|
|
|
service.pre_backup()
|
|
|
|
snapshot = Backups.provider().backupper.start_backup(
|
|
|
|
folders,
|
2023-08-21 11:11:56 +00:00
|
|
|
service_name,
|
|
|
|
reason=reason,
|
2023-07-20 14:11:02 +00:00
|
|
|
)
|
2023-08-21 12:45:31 +00:00
|
|
|
|
2023-08-21 11:11:56 +00:00
|
|
|
Backups._store_last_snapshot(service_name, snapshot)
|
2023-08-21 12:45:31 +00:00
|
|
|
if reason == BackupReason.AUTO:
|
|
|
|
Backups._prune_auto_snaps(service)
|
2023-07-20 14:11:02 +00:00
|
|
|
service.post_restore()
|
2023-07-20 15:24:26 +00:00
|
|
|
except Exception as error:
|
2023-05-08 10:55:22 +00:00
|
|
|
Jobs.update(job, status=JobStatus.ERROR)
|
2023-07-20 15:24:26 +00:00
|
|
|
raise error
|
2023-04-03 18:18:23 +00:00
|
|
|
|
2023-04-24 17:03:56 +00:00
|
|
|
Jobs.update(job, status=JobStatus.FINISHED)
|
2023-06-05 11:19:01 +00:00
|
|
|
return snapshot
|
2023-02-17 15:55:19 +00:00
|
|
|
|
2023-08-21 12:45:31 +00:00
|
|
|
@staticmethod
|
|
|
|
def _auto_snaps(service):
|
|
|
|
return [
|
|
|
|
snap
|
|
|
|
for snap in Backups.get_snapshots(service)
|
|
|
|
if snap.reason == BackupReason.AUTO
|
|
|
|
]
|
|
|
|
|
2023-08-28 17:02:45 +00:00
|
|
|
@staticmethod
|
|
|
|
def add_snap_but_with_quotas(
|
|
|
|
new_snap: Snapshot, snaps: List[Snapshot], quotas: AutobackupQuotas
|
|
|
|
) -> None:
|
|
|
|
quotas_map = {
|
|
|
|
same_day: quotas.daily,
|
|
|
|
same_week: quotas.weekly,
|
|
|
|
same_month: quotas.monthly,
|
|
|
|
same_year: quotas.yearly,
|
|
|
|
same_lifetime_of_the_universe: quotas.total,
|
|
|
|
}
|
|
|
|
|
|
|
|
snaps.append(new_snap)
|
|
|
|
|
|
|
|
for is_same_period, quota in quotas_map.items():
|
|
|
|
if quota <= 0:
|
|
|
|
continue
|
|
|
|
|
|
|
|
cohort = [
|
|
|
|
snap
|
|
|
|
for snap in snaps
|
|
|
|
if is_same_period(snap.created_at, new_snap.created_at)
|
|
|
|
]
|
|
|
|
sorted_cohort = sorted(cohort, key=lambda s: s.created_at)
|
|
|
|
n_to_kill = len(cohort) - quota
|
|
|
|
if n_to_kill > 0:
|
|
|
|
snaps_to_kill = sorted_cohort[:n_to_kill]
|
|
|
|
for snap in snaps_to_kill:
|
|
|
|
snaps.remove(snap)
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def _prune_snaps_with_quotas(snapshots: List[Snapshot]) -> List[Snapshot]:
|
|
|
|
# Function broken out for testability
|
|
|
|
sorted_snaps = sorted(snapshots, key=lambda s: s.created_at)
|
|
|
|
quotas = Backups.autobackup_quotas()
|
|
|
|
|
|
|
|
new_snaplist: List[Snapshot] = []
|
|
|
|
for snap in sorted_snaps:
|
|
|
|
Backups.add_snap_but_with_quotas(snap, new_snaplist, quotas)
|
|
|
|
|
|
|
|
return new_snaplist
|
|
|
|
|
2023-08-21 12:45:31 +00:00
|
|
|
@staticmethod
|
|
|
|
def _prune_auto_snaps(service) -> None:
|
2023-08-28 17:02:45 +00:00
|
|
|
# Not very testable by itself, so most testing is going on Backups._prune_snaps_with_quotas
|
|
|
|
# We can still test total limits and, say, daily limits
|
2023-08-21 12:45:31 +00:00
|
|
|
|
|
|
|
auto_snaps = Backups._auto_snaps(service)
|
2023-08-28 17:02:45 +00:00
|
|
|
new_snaplist = Backups._prune_snaps_with_quotas(auto_snaps)
|
|
|
|
|
|
|
|
# TODO: Can be optimized since there is forgetting of an array in one restic op
|
|
|
|
# but most of the time this will be only one snap to forget.
|
|
|
|
for snap in auto_snaps:
|
|
|
|
if snap not in new_snaplist:
|
2023-08-21 12:45:31 +00:00
|
|
|
Backups.forget_snapshot(snap)
|
|
|
|
|
2023-08-28 17:02:45 +00:00
|
|
|
@staticmethod
|
|
|
|
def _standardize_quotas(i: int) -> int:
|
|
|
|
if i <= 0:
|
|
|
|
i = -1
|
|
|
|
return i
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def autobackup_quotas() -> AutobackupQuotas:
|
|
|
|
"""everything <=0 means unlimited"""
|
|
|
|
|
|
|
|
return Storage.autobackup_quotas()
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def set_autobackup_quotas(quotas: AutobackupQuotas) -> None:
|
|
|
|
"""everything <=0 means unlimited"""
|
|
|
|
|
|
|
|
Storage.set_autobackup_quotas(
|
|
|
|
AutobackupQuotas(
|
|
|
|
daily=Backups._standardize_quotas(quotas.daily),
|
|
|
|
weekly=Backups._standardize_quotas(quotas.weekly),
|
|
|
|
monthly=Backups._standardize_quotas(quotas.monthly),
|
|
|
|
yearly=Backups._standardize_quotas(quotas.yearly),
|
|
|
|
total=Backups._standardize_quotas(quotas.total),
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
2023-08-21 12:45:31 +00:00
|
|
|
@staticmethod
|
|
|
|
def set_max_auto_snapshots(value: int) -> None:
|
|
|
|
"""everything <=0 means unlimited"""
|
|
|
|
if value <= 0:
|
|
|
|
value = -1
|
|
|
|
Storage.set_max_auto_snapshots(value)
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def max_auto_snapshots() -> int:
|
|
|
|
"""-1 means unlimited"""
|
|
|
|
return Storage.max_auto_snapshots()
|
|
|
|
|
2023-07-18 17:15:22 +00:00
|
|
|
# Restoring
|
|
|
|
|
2023-07-07 10:50:59 +00:00
|
|
|
@staticmethod
|
2023-07-07 11:54:48 +00:00
|
|
|
def _ensure_queued_restore_job(service, snapshot) -> Job:
|
2023-07-07 10:50:59 +00:00
|
|
|
job = get_restore_job(service)
|
|
|
|
if job is None:
|
|
|
|
job = add_restore_job(snapshot)
|
|
|
|
|
2023-07-07 11:54:48 +00:00
|
|
|
Jobs.update(job, status=JobStatus.CREATED)
|
2023-07-07 10:50:59 +00:00
|
|
|
return job
|
2023-03-14 00:39:15 +00:00
|
|
|
|
2023-07-07 11:54:48 +00:00
|
|
|
@staticmethod
|
2023-07-19 12:59:29 +00:00
|
|
|
def _inplace_restore(
|
|
|
|
service: Service,
|
|
|
|
snapshot: Snapshot,
|
|
|
|
job: Job,
|
|
|
|
) -> None:
|
2023-08-21 11:11:56 +00:00
|
|
|
failsafe_snapshot = Backups.back_up(service, BackupReason.PRE_RESTORE)
|
2023-07-07 11:54:48 +00:00
|
|
|
|
|
|
|
Jobs.update(job, status=JobStatus.RUNNING)
|
|
|
|
try:
|
2023-07-18 17:15:22 +00:00
|
|
|
Backups._restore_service_from_snapshot(
|
|
|
|
service,
|
|
|
|
snapshot.id,
|
|
|
|
verify=False,
|
|
|
|
)
|
2023-07-20 15:24:26 +00:00
|
|
|
except Exception as error:
|
2023-07-07 11:54:48 +00:00
|
|
|
Backups._restore_service_from_snapshot(
|
|
|
|
service, failsafe_snapshot.id, verify=False
|
|
|
|
)
|
2023-07-20 15:24:26 +00:00
|
|
|
raise error
|
2023-07-07 11:54:48 +00:00
|
|
|
|
2023-06-26 18:30:31 +00:00
|
|
|
@staticmethod
|
2023-07-07 10:50:59 +00:00
|
|
|
def restore_snapshot(
|
|
|
|
snapshot: Snapshot, strategy=RestoreStrategy.DOWNLOAD_VERIFY_OVERWRITE
|
2023-07-19 12:59:29 +00:00
|
|
|
) -> None:
|
2023-07-20 15:24:26 +00:00
|
|
|
"""Restores a snapshot to its original service using the given strategy"""
|
2023-06-26 18:30:31 +00:00
|
|
|
service = get_service_by_id(snapshot.service_name)
|
|
|
|
if service is None:
|
|
|
|
raise ValueError(
|
|
|
|
f"snapshot has a nonexistent service: {snapshot.service_name}"
|
|
|
|
)
|
2023-07-07 11:54:48 +00:00
|
|
|
job = Backups._ensure_queued_restore_job(service, snapshot)
|
2023-06-26 18:30:31 +00:00
|
|
|
|
|
|
|
try:
|
2023-06-26 18:42:26 +00:00
|
|
|
Backups._assert_restorable(snapshot)
|
2023-07-12 16:53:49 +00:00
|
|
|
with StoppedService(service):
|
|
|
|
Backups.assert_dead(service)
|
|
|
|
if strategy == RestoreStrategy.INPLACE:
|
|
|
|
Backups._inplace_restore(service, snapshot, job)
|
|
|
|
else: # verify_before_download is our default
|
|
|
|
Jobs.update(job, status=JobStatus.RUNNING)
|
|
|
|
Backups._restore_service_from_snapshot(
|
|
|
|
service, snapshot.id, verify=True
|
|
|
|
)
|
2023-07-07 11:54:48 +00:00
|
|
|
|
2023-07-12 16:53:49 +00:00
|
|
|
service.post_restore()
|
2023-07-07 11:54:48 +00:00
|
|
|
|
2023-07-20 15:24:26 +00:00
|
|
|
except Exception as error:
|
2023-07-07 10:50:59 +00:00
|
|
|
Jobs.update(job, status=JobStatus.ERROR)
|
2023-07-20 15:24:26 +00:00
|
|
|
raise error
|
2023-06-26 18:30:31 +00:00
|
|
|
|
2023-07-07 10:50:59 +00:00
|
|
|
Jobs.update(job, status=JobStatus.FINISHED)
|
2023-02-17 16:11:17 +00:00
|
|
|
|
2023-06-26 18:42:26 +00:00
|
|
|
@staticmethod
|
2023-07-14 12:34:45 +00:00
|
|
|
def _assert_restorable(
|
|
|
|
snapshot: Snapshot, strategy=RestoreStrategy.DOWNLOAD_VERIFY_OVERWRITE
|
2023-07-19 12:59:29 +00:00
|
|
|
) -> None:
|
2023-06-26 18:42:26 +00:00
|
|
|
service = get_service_by_id(snapshot.service_name)
|
|
|
|
if service is None:
|
|
|
|
raise ValueError(
|
|
|
|
f"snapshot has a nonexistent service: {snapshot.service_name}"
|
|
|
|
)
|
|
|
|
|
2023-07-14 12:34:45 +00:00
|
|
|
restored_snap_size = Backups.snapshot_restored_size(snapshot.id)
|
|
|
|
|
|
|
|
if strategy == RestoreStrategy.DOWNLOAD_VERIFY_OVERWRITE:
|
|
|
|
needed_space = restored_snap_size
|
|
|
|
elif strategy == RestoreStrategy.INPLACE:
|
|
|
|
needed_space = restored_snap_size - service.get_storage_usage()
|
|
|
|
else:
|
|
|
|
raise NotImplementedError(
|
|
|
|
"""
|
2023-07-18 17:15:22 +00:00
|
|
|
We do not know if there is enough space for restoration because
|
|
|
|
there is some novel restore strategy used!
|
|
|
|
This is a developer's fault, open an issue please
|
2023-07-14 12:34:45 +00:00
|
|
|
"""
|
|
|
|
)
|
2023-06-26 18:42:26 +00:00
|
|
|
available_space = Backups.space_usable_for_service(service)
|
|
|
|
if needed_space > available_space:
|
|
|
|
raise ValueError(
|
|
|
|
f"we only have {available_space} bytes "
|
|
|
|
f"but snapshot needs {needed_space}"
|
|
|
|
)
|
|
|
|
|
|
|
|
@staticmethod
|
2023-07-18 17:15:22 +00:00
|
|
|
def _restore_service_from_snapshot(
|
|
|
|
service: Service,
|
|
|
|
snapshot_id: str,
|
|
|
|
verify=True,
|
2023-07-19 12:59:29 +00:00
|
|
|
) -> None:
|
2023-06-26 18:42:26 +00:00
|
|
|
folders = service.get_folders()
|
|
|
|
|
|
|
|
Backups.provider().backupper.restore_from_backup(
|
|
|
|
snapshot_id,
|
|
|
|
folders,
|
2023-07-18 17:15:22 +00:00
|
|
|
verify=verify,
|
2023-06-26 18:42:26 +00:00
|
|
|
)
|
|
|
|
|
2023-07-18 17:15:22 +00:00
|
|
|
# Snapshots
|
2023-06-26 18:00:42 +00:00
|
|
|
|
2023-03-29 11:15:38 +00:00
|
|
|
@staticmethod
|
|
|
|
def get_snapshots(service: Service) -> List[Snapshot]:
|
2023-07-20 15:24:26 +00:00
|
|
|
"""Returns all snapshots for a given service"""
|
2023-06-05 11:28:53 +00:00
|
|
|
snapshots = Backups.get_all_snapshots()
|
2023-06-23 09:40:10 +00:00
|
|
|
service_id = service.get_id()
|
|
|
|
return list(
|
|
|
|
filter(
|
|
|
|
lambda snap: snap.service_name == service_id,
|
|
|
|
snapshots,
|
|
|
|
)
|
|
|
|
)
|
2023-06-05 11:28:53 +00:00
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def get_all_snapshots() -> List[Snapshot]:
|
2023-07-20 15:24:26 +00:00
|
|
|
"""Returns all snapshots"""
|
2023-06-05 11:28:53 +00:00
|
|
|
cached_snapshots = Storage.get_cached_snapshots()
|
2023-07-20 15:24:26 +00:00
|
|
|
if cached_snapshots:
|
2023-04-07 17:24:53 +00:00
|
|
|
return cached_snapshots
|
|
|
|
# TODO: the oldest snapshots will get expired faster than the new ones.
|
|
|
|
# How to detect that the end is missing?
|
|
|
|
|
2023-06-26 19:20:49 +00:00
|
|
|
Backups.force_snapshot_cache_reload()
|
2023-06-26 19:01:26 +00:00
|
|
|
return Storage.get_cached_snapshots()
|
2023-02-22 14:45:11 +00:00
|
|
|
|
2023-06-01 14:03:26 +00:00
|
|
|
@staticmethod
|
2023-07-20 15:24:26 +00:00
|
|
|
def get_snapshot_by_id(snapshot_id: str) -> Optional[Snapshot]:
|
|
|
|
"""Returns a backup snapshot by its id"""
|
|
|
|
snap = Storage.get_cached_snapshot_by_id(snapshot_id)
|
2023-06-01 14:03:26 +00:00
|
|
|
if snap is not None:
|
|
|
|
return snap
|
|
|
|
|
|
|
|
# Possibly our cache entry got invalidated, let's try one more time
|
2023-06-26 19:20:49 +00:00
|
|
|
Backups.force_snapshot_cache_reload()
|
2023-07-20 15:24:26 +00:00
|
|
|
snap = Storage.get_cached_snapshot_by_id(snapshot_id)
|
2023-06-01 14:03:26 +00:00
|
|
|
|
|
|
|
return snap
|
|
|
|
|
2023-07-05 13:13:30 +00:00
|
|
|
@staticmethod
|
2023-07-19 12:59:29 +00:00
|
|
|
def forget_snapshot(snapshot: Snapshot) -> None:
|
2023-07-26 14:26:04 +00:00
|
|
|
"""Deletes a snapshot from the repo and from cache"""
|
2023-07-05 13:13:30 +00:00
|
|
|
Backups.provider().backupper.forget_snapshot(snapshot.id)
|
|
|
|
Storage.delete_cached_snapshot(snapshot)
|
|
|
|
|
2023-07-26 14:26:04 +00:00
|
|
|
@staticmethod
|
|
|
|
def forget_all_snapshots():
|
|
|
|
"""deliberately erase all snapshots we made"""
|
|
|
|
# there is no dedicated optimized command for this,
|
|
|
|
# but maybe we can have a multi-erase
|
|
|
|
for snapshot in Backups.get_all_snapshots():
|
|
|
|
Backups.forget_snapshot(snapshot)
|
|
|
|
|
2023-06-01 16:12:32 +00:00
|
|
|
@staticmethod
|
2023-07-19 12:59:29 +00:00
|
|
|
def force_snapshot_cache_reload() -> None:
|
2023-07-20 15:24:26 +00:00
|
|
|
"""
|
|
|
|
Forces a reload of the snapshot cache.
|
|
|
|
|
|
|
|
This may be an expensive operation, so use it wisely.
|
|
|
|
User pays for the API calls.
|
|
|
|
"""
|
2023-06-23 12:04:33 +00:00
|
|
|
upstream_snapshots = Backups.provider().backupper.get_snapshots()
|
2023-06-01 14:03:26 +00:00
|
|
|
Storage.invalidate_snapshot_storage()
|
|
|
|
for snapshot in upstream_snapshots:
|
|
|
|
Storage.cache_snapshot(snapshot)
|
|
|
|
|
2023-06-26 18:00:42 +00:00
|
|
|
@staticmethod
|
2023-06-26 19:41:18 +00:00
|
|
|
def snapshot_restored_size(snapshot_id: str) -> int:
|
2023-07-20 15:24:26 +00:00
|
|
|
"""Returns the size of the snapshot"""
|
2023-06-26 18:00:42 +00:00
|
|
|
return Backups.provider().backupper.restored_size(
|
|
|
|
snapshot_id,
|
|
|
|
)
|
|
|
|
|
|
|
|
@staticmethod
|
2023-07-19 12:59:29 +00:00
|
|
|
def _store_last_snapshot(service_id: str, snapshot: Snapshot) -> None:
|
2023-06-26 18:00:42 +00:00
|
|
|
"""What do we do with a snapshot that is just made?"""
|
|
|
|
# non-expiring timestamp of the last
|
|
|
|
Storage.store_last_timestamp(service_id, snapshot)
|
|
|
|
# expiring cache entry
|
|
|
|
Storage.cache_snapshot(snapshot)
|
|
|
|
|
2023-07-18 17:15:22 +00:00
|
|
|
# Autobackup
|
2023-06-26 18:00:42 +00:00
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def autobackup_period_minutes() -> Optional[int]:
|
|
|
|
"""None means autobackup is disabled"""
|
|
|
|
return Storage.autobackup_period_minutes()
|
|
|
|
|
|
|
|
@staticmethod
|
2023-07-19 12:59:29 +00:00
|
|
|
def set_autobackup_period_minutes(minutes: int) -> None:
|
2023-06-26 18:00:42 +00:00
|
|
|
"""
|
|
|
|
0 and negative numbers are equivalent to disable.
|
|
|
|
Setting to a positive number may result in a backup very soon
|
|
|
|
if some services are not backed up.
|
|
|
|
"""
|
|
|
|
if minutes <= 0:
|
|
|
|
Backups.disable_all_autobackup()
|
|
|
|
return
|
|
|
|
Storage.store_autobackup_period_minutes(minutes)
|
|
|
|
|
2023-07-19 15:35:24 +00:00
|
|
|
@staticmethod
|
|
|
|
def disable_all_autobackup() -> None:
|
|
|
|
"""
|
|
|
|
Disables all automatic backing up,
|
|
|
|
but does not change per-service settings
|
|
|
|
"""
|
|
|
|
Storage.delete_backup_period()
|
|
|
|
|
2023-06-26 18:00:42 +00:00
|
|
|
@staticmethod
|
|
|
|
def is_time_to_backup(time: datetime) -> bool:
|
|
|
|
"""
|
|
|
|
Intended as a time validator for huey cron scheduler
|
|
|
|
of automatic backups
|
|
|
|
"""
|
|
|
|
|
2023-07-19 15:35:24 +00:00
|
|
|
return Backups.services_to_back_up(time) != []
|
2023-06-26 18:00:42 +00:00
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def services_to_back_up(time: datetime) -> List[Service]:
|
2023-07-20 15:24:26 +00:00
|
|
|
"""Returns a list of services that should be backed up at a given time"""
|
2023-07-19 15:35:24 +00:00
|
|
|
return [
|
|
|
|
service
|
|
|
|
for service in get_all_services()
|
|
|
|
if Backups.is_time_to_backup_service(service, time)
|
|
|
|
]
|
2023-06-26 18:00:42 +00:00
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def get_last_backed_up(service: Service) -> Optional[datetime]:
|
|
|
|
"""Get a timezone-aware time of the last backup of a service"""
|
|
|
|
return Storage.get_last_backup_time(service.get_id())
|
|
|
|
|
|
|
|
@staticmethod
|
2023-07-19 15:35:24 +00:00
|
|
|
def is_time_to_backup_service(service: Service, time: datetime):
|
2023-07-20 15:24:26 +00:00
|
|
|
"""Returns True if it is time to back up a service"""
|
2023-06-26 18:00:42 +00:00
|
|
|
period = Backups.autobackup_period_minutes()
|
2023-07-19 15:35:24 +00:00
|
|
|
service_id = service.get_id()
|
|
|
|
if not service.can_be_backed_up():
|
2023-06-26 18:00:42 +00:00
|
|
|
return False
|
2023-07-19 15:35:24 +00:00
|
|
|
if period is None:
|
2023-06-26 18:00:42 +00:00
|
|
|
return False
|
|
|
|
|
|
|
|
last_backup = Storage.get_last_backup_time(service_id)
|
|
|
|
if last_backup is None:
|
|
|
|
# queue a backup immediately if there are no previous backups
|
|
|
|
return True
|
|
|
|
|
|
|
|
if time > last_backup + timedelta(minutes=period):
|
|
|
|
return True
|
|
|
|
return False
|
|
|
|
|
2023-07-18 17:15:22 +00:00
|
|
|
# Helpers
|
2023-04-10 13:22:33 +00:00
|
|
|
|
2023-06-07 16:33:13 +00:00
|
|
|
@staticmethod
|
2023-06-23 09:40:10 +00:00
|
|
|
def space_usable_for_service(service: Service) -> int:
|
2023-07-20 15:24:26 +00:00
|
|
|
"""
|
|
|
|
Returns the amount of space available on the volume the given
|
|
|
|
service is located on.
|
|
|
|
"""
|
2023-06-07 16:33:13 +00:00
|
|
|
folders = service.get_folders()
|
|
|
|
if folders == []:
|
|
|
|
raise ValueError("unallocated service", service.get_id())
|
|
|
|
|
2023-07-14 12:34:45 +00:00
|
|
|
# We assume all folders of one service live at the same volume
|
2023-06-07 16:33:13 +00:00
|
|
|
fs_info = statvfs(folders[0])
|
|
|
|
usable_bytes = fs_info.f_frsize * fs_info.f_bavail
|
|
|
|
return usable_bytes
|
|
|
|
|
2023-04-10 13:22:33 +00:00
|
|
|
@staticmethod
|
2023-06-26 18:00:42 +00:00
|
|
|
def set_localfile_repo(file_path: str):
|
2023-07-20 15:24:26 +00:00
|
|
|
"""Used by tests to set a local folder as a backup repo"""
|
|
|
|
# pylint: disable-next=invalid-name
|
2023-06-26 18:00:42 +00:00
|
|
|
ProviderClass = get_provider(BackupProviderEnum.FILE)
|
|
|
|
provider = ProviderClass(
|
|
|
|
login="",
|
|
|
|
key="",
|
|
|
|
location=file_path,
|
|
|
|
repo_id="",
|
|
|
|
)
|
|
|
|
Storage.store_provider(provider)
|
2023-07-12 16:43:26 +00:00
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def assert_dead(service: Service):
|
2023-07-20 14:11:02 +00:00
|
|
|
"""
|
2023-07-20 15:24:26 +00:00
|
|
|
Checks if a service is dead and can be safely restored from a snapshot.
|
2023-07-20 14:11:02 +00:00
|
|
|
"""
|
2023-07-18 17:15:22 +00:00
|
|
|
if service.get_status() not in [
|
|
|
|
ServiceStatus.INACTIVE,
|
|
|
|
ServiceStatus.FAILED,
|
|
|
|
]:
|
2023-07-12 16:43:26 +00:00
|
|
|
raise NotDeadError(service)
|