Merge pull request 'quotas for autobackups' (#56) from quotas into master

Reviewed-on: https://git.selfprivacy.org/SelfPrivacy/selfprivacy-rest-api/pulls/56
This commit is contained in:
Inex Code 2023-09-09 03:50:18 +03:00
commit 6b106cbcf3
15 changed files with 736 additions and 58 deletions

View file

@ -4,7 +4,7 @@ This module contains the controller class for backups.
from datetime import datetime, timedelta from datetime import datetime, timedelta
import os import os
from os import statvfs from os import statvfs
from typing import List, Optional from typing import Callable, List, Optional
from selfprivacy_api.utils import ReadUserData, WriteUserData from selfprivacy_api.utils import ReadUserData, WriteUserData
@ -23,7 +23,12 @@ from selfprivacy_api.jobs import Jobs, JobStatus, Job
from selfprivacy_api.graphql.queries.providers import ( from selfprivacy_api.graphql.queries.providers import (
BackupProvider as BackupProviderEnum, BackupProvider as BackupProviderEnum,
) )
from selfprivacy_api.graphql.common_types.backup import RestoreStrategy from selfprivacy_api.graphql.common_types.backup import (
RestoreStrategy,
BackupReason,
AutobackupQuotas,
)
from selfprivacy_api.models.backup.snapshot import Snapshot from selfprivacy_api.models.backup.snapshot import Snapshot
@ -70,6 +75,24 @@ class NotDeadError(AssertionError):
""" """
class RotationBucket:
"""
Bucket object used for rotation.
Has the following mutable fields:
- the counter, int
- the lambda function which takes datetime and the int and returns the int
- the last, int
"""
def __init__(self, counter: int, last: int, rotation_lambda):
self.counter: int = counter
self.last: int = last
self.rotation_lambda: Callable[[datetime, int], int] = rotation_lambda
def __str__(self) -> str:
return f"Bucket(counter={self.counter}, last={self.last})"
class Backups: class Backups:
"""A stateless controller class for backups""" """A stateless controller class for backups"""
@ -264,10 +287,12 @@ class Backups:
# Backup # Backup
@staticmethod @staticmethod
def back_up(service: Service) -> Snapshot: def back_up(
service: Service, reason: BackupReason = BackupReason.EXPLICIT
) -> Snapshot:
"""The top-level function to back up a service""" """The top-level function to back up a service"""
folders = service.get_folders() folders = service.get_folders()
tag = service.get_id() service_name = service.get_id()
job = get_backup_job(service) job = get_backup_job(service)
if job is None: if job is None:
@ -278,9 +303,13 @@ class Backups:
service.pre_backup() service.pre_backup()
snapshot = Backups.provider().backupper.start_backup( snapshot = Backups.provider().backupper.start_backup(
folders, folders,
tag, service_name,
reason=reason,
) )
Backups._store_last_snapshot(tag, snapshot)
Backups._store_last_snapshot(service_name, snapshot)
if reason == BackupReason.AUTO:
Backups._prune_auto_snaps(service)
service.post_restore() service.post_restore()
except Exception as error: except Exception as error:
Jobs.update(job, status=JobStatus.ERROR, status_text=str(error)) Jobs.update(job, status=JobStatus.ERROR, status_text=str(error))
@ -289,6 +318,108 @@ class Backups:
Jobs.update(job, status=JobStatus.FINISHED) Jobs.update(job, status=JobStatus.FINISHED)
return snapshot return snapshot
@staticmethod
def _auto_snaps(service):
return [
snap
for snap in Backups.get_snapshots(service)
if snap.reason == BackupReason.AUTO
]
@staticmethod
def _prune_snaps_with_quotas(snapshots: List[Snapshot]) -> List[Snapshot]:
# Function broken out for testability
# Sorting newest first
sorted_snaps = sorted(snapshots, key=lambda s: s.created_at, reverse=True)
quotas: AutobackupQuotas = Backups.autobackup_quotas()
buckets: list[RotationBucket] = [
RotationBucket(
quotas.last,
-1,
lambda _, index: index,
),
RotationBucket(
quotas.daily,
-1,
lambda date, _: date.year * 10000 + date.month * 100 + date.day,
),
RotationBucket(
quotas.weekly,
-1,
lambda date, _: date.year * 100 + date.isocalendar()[1],
),
RotationBucket(
quotas.monthly,
-1,
lambda date, _: date.year * 100 + date.month,
),
RotationBucket(
quotas.yearly,
-1,
lambda date, _: date.year,
),
]
new_snaplist: List[Snapshot] = []
for i, snap in enumerate(sorted_snaps):
keep_snap = False
for bucket in buckets:
if (bucket.counter > 0) or (bucket.counter == -1):
val = bucket.rotation_lambda(snap.created_at, i)
if (val != bucket.last) or (i == len(sorted_snaps) - 1):
bucket.last = val
if bucket.counter > 0:
bucket.counter -= 1
if not keep_snap:
new_snaplist.append(snap)
keep_snap = True
return new_snaplist
@staticmethod
def _prune_auto_snaps(service) -> None:
# 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
auto_snaps = Backups._auto_snaps(service)
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:
Backups.forget_snapshot(snap)
@staticmethod
def _standardize_quotas(i: int) -> int:
if i <= -1:
i = -1
return i
@staticmethod
def autobackup_quotas() -> AutobackupQuotas:
"""0 means do not keep, -1 means unlimited"""
return Storage.autobackup_quotas()
@staticmethod
def set_autobackup_quotas(quotas: AutobackupQuotas) -> None:
"""0 means do not keep, -1 means unlimited"""
Storage.set_autobackup_quotas(
AutobackupQuotas(
last=Backups._standardize_quotas(quotas.last),
daily=Backups._standardize_quotas(quotas.daily),
weekly=Backups._standardize_quotas(quotas.weekly),
monthly=Backups._standardize_quotas(quotas.monthly),
yearly=Backups._standardize_quotas(quotas.yearly),
)
)
for service in get_all_services():
Backups._prune_auto_snaps(service)
# Restoring # Restoring
@staticmethod @staticmethod
@ -309,7 +440,7 @@ class Backups:
Jobs.update( Jobs.update(
job, status=JobStatus.CREATED, status_text=f"Waiting for pre-restore backup" job, status=JobStatus.CREATED, status_text=f"Waiting for pre-restore backup"
) )
failsafe_snapshot = Backups.back_up(service) failsafe_snapshot = Backups.back_up(service, BackupReason.PRE_RESTORE)
Jobs.update( Jobs.update(
job, status=JobStatus.RUNNING, status_text=f"Restoring from {snapshot.id}" job, status=JobStatus.RUNNING, status_text=f"Restoring from {snapshot.id}"

View file

@ -2,6 +2,7 @@ from abc import ABC, abstractmethod
from typing import List from typing import List
from selfprivacy_api.models.backup.snapshot import Snapshot from selfprivacy_api.models.backup.snapshot import Snapshot
from selfprivacy_api.graphql.common_types.backup import BackupReason
class AbstractBackupper(ABC): class AbstractBackupper(ABC):
@ -22,7 +23,12 @@ class AbstractBackupper(ABC):
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
def start_backup(self, folders: List[str], tag: str) -> Snapshot: def start_backup(
self,
folders: List[str],
service_name: str,
reason: BackupReason = BackupReason.EXPLICIT,
) -> Snapshot:
"""Start a backup of the given folders""" """Start a backup of the given folders"""
raise NotImplementedError raise NotImplementedError

View file

@ -2,6 +2,7 @@ from typing import List
from selfprivacy_api.models.backup.snapshot import Snapshot from selfprivacy_api.models.backup.snapshot import Snapshot
from selfprivacy_api.backup.backuppers import AbstractBackupper from selfprivacy_api.backup.backuppers import AbstractBackupper
from selfprivacy_api.graphql.common_types.backup import BackupReason
class NoneBackupper(AbstractBackupper): class NoneBackupper(AbstractBackupper):
@ -13,7 +14,9 @@ class NoneBackupper(AbstractBackupper):
def set_creds(self, account: str, key: str, repo: str): def set_creds(self, account: str, key: str, repo: str):
pass pass
def start_backup(self, folders: List[str], tag: str): def start_backup(
self, folders: List[str], tag: str, reason: BackupReason = BackupReason.EXPLICIT
):
raise NotImplementedError raise NotImplementedError
def get_snapshots(self) -> List[Snapshot]: def get_snapshots(self) -> List[Snapshot]:

View file

@ -5,13 +5,14 @@ import json
import datetime import datetime
import tempfile import tempfile
from typing import List, TypeVar, Callable from typing import List, Optional, TypeVar, Callable
from collections.abc import Iterable from collections.abc import Iterable
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
from os.path import exists, join from os.path import exists, join
from os import mkdir from os import mkdir
from shutil import rmtree from shutil import rmtree
from selfprivacy_api.graphql.common_types.backup import BackupReason
from selfprivacy_api.backup.util import output_yielder, sync from selfprivacy_api.backup.util import output_yielder, sync
from selfprivacy_api.backup.backuppers import AbstractBackupper from selfprivacy_api.backup.backuppers import AbstractBackupper
from selfprivacy_api.models.backup.snapshot import Snapshot from selfprivacy_api.models.backup.snapshot import Snapshot
@ -84,7 +85,10 @@ class ResticBackupper(AbstractBackupper):
def _password_command(self): def _password_command(self):
return f"echo {LocalBackupSecret.get()}" return f"echo {LocalBackupSecret.get()}"
def restic_command(self, *args, tag: str = "") -> List[str]: def restic_command(self, *args, tags: Optional[List[str]] = None) -> List[str]:
if tags is None:
tags = []
command = [ command = [
"restic", "restic",
"-o", "-o",
@ -94,13 +98,14 @@ class ResticBackupper(AbstractBackupper):
"--password-command", "--password-command",
self._password_command(), self._password_command(),
] ]
if tag != "": if tags != []:
command.extend( for tag in tags:
[ command.extend(
"--tag", [
tag, "--tag",
] tag,
) ]
)
if args: if args:
command.extend(ResticBackupper.__flatten_list(args)) command.extend(ResticBackupper.__flatten_list(args))
return command return command
@ -138,7 +143,12 @@ class ResticBackupper(AbstractBackupper):
return result return result
@unlocked_repo @unlocked_repo
def start_backup(self, folders: List[str], tag: str) -> Snapshot: def start_backup(
self,
folders: List[str],
service_name: str,
reason: BackupReason = BackupReason.EXPLICIT,
) -> Snapshot:
""" """
Start backup with restic Start backup with restic
""" """
@ -147,33 +157,35 @@ class ResticBackupper(AbstractBackupper):
# of a string and an array of strings # of a string and an array of strings
assert not isinstance(folders, str) assert not isinstance(folders, str)
tags = [service_name, reason.value]
backup_command = self.restic_command( backup_command = self.restic_command(
"backup", "backup",
"--json", "--json",
folders, folders,
tag=tag, tags=tags,
) )
messages = [] service = get_service_by_id(service_name)
service = get_service_by_id(tag)
if service is None: if service is None:
raise ValueError("No service with id ", tag) raise ValueError("No service with id ", service_name)
job = get_backup_job(service) job = get_backup_job(service)
messages = []
output = [] output = []
try: try:
for raw_message in output_yielder(backup_command): for raw_message in output_yielder(backup_command):
output.append(raw_message) output.append(raw_message)
message = self.parse_message( message = self.parse_message(raw_message, job)
raw_message,
job,
)
messages.append(message) messages.append(message)
return ResticBackupper._snapshot_from_backup_messages( id = ResticBackupper._snapshot_id_from_backup_messages(messages)
messages, return Snapshot(
tag, created_at=datetime.datetime.now(datetime.timezone.utc),
id=id,
service_name=service_name,
reason=reason,
) )
except ValueError as error: except ValueError as error:
raise ValueError( raise ValueError(
"Could not create a snapshot: ", "Could not create a snapshot: ",
@ -184,13 +196,13 @@ class ResticBackupper(AbstractBackupper):
) from error ) from error
@staticmethod @staticmethod
def _snapshot_from_backup_messages(messages, repo_name) -> Snapshot: def _snapshot_id_from_backup_messages(messages) -> str:
for message in messages: for message in messages:
if message["message_type"] == "summary": if message["message_type"] == "summary":
return ResticBackupper._snapshot_from_fresh_summary( # There is a discrepancy between versions of restic/rclone
message, # Some report short_id in this field and some full
repo_name, return message["snapshot_id"][0:SHORT_ID_LEN]
)
raise ValueError("no summary message in restic json output") raise ValueError("no summary message in restic json output")
def parse_message(self, raw_message_line: str, job=None) -> dict: def parse_message(self, raw_message_line: str, job=None) -> dict:
@ -206,16 +218,6 @@ class ResticBackupper(AbstractBackupper):
) )
return message return message
@staticmethod
def _snapshot_from_fresh_summary(message: dict, repo_name) -> Snapshot:
return Snapshot(
# There is a discrepancy between versions of restic/rclone
# Some report short_id in this field and some full
id=message["snapshot_id"][0:SHORT_ID_LEN],
created_at=datetime.datetime.now(datetime.timezone.utc),
service_name=repo_name,
)
def init(self) -> None: def init(self) -> None:
init_command = self.restic_command( init_command = self.restic_command(
"init", "init",
@ -391,6 +393,8 @@ class ResticBackupper(AbstractBackupper):
forget_command = self.restic_command( forget_command = self.restic_command(
"forget", "forget",
snapshot_id, snapshot_id,
# TODO: prune should be done in a separate process
"--prune",
) )
with subprocess.Popen( with subprocess.Popen(
@ -450,11 +454,19 @@ class ResticBackupper(AbstractBackupper):
def get_snapshots(self) -> List[Snapshot]: def get_snapshots(self) -> List[Snapshot]:
"""Get all snapshots from the repo""" """Get all snapshots from the repo"""
snapshots = [] snapshots = []
for restic_snapshot in self._load_snapshots(): for restic_snapshot in self._load_snapshots():
# Compatibility with previous snaps:
if len(restic_snapshot["tags"]) == 1:
reason = BackupReason.EXPLICIT
else:
reason = restic_snapshot["tags"][1]
snapshot = Snapshot( snapshot = Snapshot(
id=restic_snapshot["short_id"], id=restic_snapshot["short_id"],
created_at=restic_snapshot["time"], created_at=restic_snapshot["time"],
service_name=restic_snapshot["tags"][0], service_name=restic_snapshot["tags"][0],
reason=reason,
) )
snapshots.append(snapshot) snapshots.append(snapshot)

View file

@ -6,6 +6,10 @@ from datetime import datetime
from selfprivacy_api.models.backup.snapshot import Snapshot from selfprivacy_api.models.backup.snapshot import Snapshot
from selfprivacy_api.models.backup.provider import BackupProviderModel from selfprivacy_api.models.backup.provider import BackupProviderModel
from selfprivacy_api.graphql.common_types.backup import (
AutobackupQuotas,
_AutobackupQuotas,
)
from selfprivacy_api.utils.redis_pool import RedisPool from selfprivacy_api.utils.redis_pool import RedisPool
from selfprivacy_api.utils.redis_model_storage import ( from selfprivacy_api.utils.redis_model_storage import (
@ -23,6 +27,8 @@ REDIS_INITTED_CACHE = "backups:repo_initted"
REDIS_PROVIDER_KEY = "backups:provider" REDIS_PROVIDER_KEY = "backups:provider"
REDIS_AUTOBACKUP_PERIOD_KEY = "backups:autobackup_period" REDIS_AUTOBACKUP_PERIOD_KEY = "backups:autobackup_period"
REDIS_AUTOBACKUP_QUOTAS_KEY = "backups:autobackup_quotas_key"
redis = RedisPool().get_connection() redis = RedisPool().get_connection()
@ -35,6 +41,7 @@ class Storage:
redis.delete(REDIS_PROVIDER_KEY) redis.delete(REDIS_PROVIDER_KEY)
redis.delete(REDIS_AUTOBACKUP_PERIOD_KEY) redis.delete(REDIS_AUTOBACKUP_PERIOD_KEY)
redis.delete(REDIS_INITTED_CACHE) redis.delete(REDIS_INITTED_CACHE)
redis.delete(REDIS_AUTOBACKUP_QUOTAS_KEY)
prefixes_to_clean = [ prefixes_to_clean = [
REDIS_SNAPSHOTS_PREFIX, REDIS_SNAPSHOTS_PREFIX,
@ -170,3 +177,23 @@ class Storage:
def mark_as_uninitted(): def mark_as_uninitted():
"""Marks the repository as initialized""" """Marks the repository as initialized"""
redis.delete(REDIS_INITTED_CACHE) redis.delete(REDIS_INITTED_CACHE)
@staticmethod
def set_autobackup_quotas(quotas: AutobackupQuotas) -> None:
store_model_as_hash(redis, REDIS_AUTOBACKUP_QUOTAS_KEY, quotas.to_pydantic())
@staticmethod
def autobackup_quotas() -> AutobackupQuotas:
quotas_model = hash_as_model(
redis, REDIS_AUTOBACKUP_QUOTAS_KEY, _AutobackupQuotas
)
if quotas_model is None:
unlimited_quotas = AutobackupQuotas(
last=-1,
daily=-1,
weekly=-1,
monthly=-1,
yearly=-1,
)
return unlimited_quotas
return AutobackupQuotas.from_pydantic(quotas_model) # pylint: disable=no-member

View file

@ -3,7 +3,7 @@ The tasks module contains the worker tasks that are used to back up and restore
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
from selfprivacy_api.graphql.common_types.backup import RestoreStrategy from selfprivacy_api.graphql.common_types.backup import RestoreStrategy, BackupReason
from selfprivacy_api.models.backup.snapshot import Snapshot from selfprivacy_api.models.backup.snapshot import Snapshot
from selfprivacy_api.utils.huey import huey from selfprivacy_api.utils.huey import huey
@ -26,11 +26,13 @@ def validate_datetime(dt: datetime) -> bool:
# huey tasks need to return something # huey tasks need to return something
@huey.task() @huey.task()
def start_backup(service: Service) -> bool: def start_backup(
service: Service, reason: BackupReason = BackupReason.EXPLICIT
) -> bool:
""" """
The worker task that starts the backup process. The worker task that starts the backup process.
""" """
Backups.back_up(service) Backups.back_up(service, reason)
return True return True
@ -53,7 +55,7 @@ def automatic_backup():
""" """
time = datetime.utcnow().replace(tzinfo=timezone.utc) time = datetime.utcnow().replace(tzinfo=timezone.utc)
for service in Backups.services_to_back_up(time): for service in Backups.services_to_back_up(time):
start_backup(service) start_backup(service, BackupReason.AUTO)
@huey.periodic_task(crontab(hour=SNAPSHOT_CACHE_TTL_HOURS)) @huey.periodic_task(crontab(hour=SNAPSHOT_CACHE_TTL_HOURS))

View file

@ -27,4 +27,4 @@ async def get_token_header(
def get_api_version() -> str: def get_api_version() -> str:
"""Get API version""" """Get API version"""
return "2.3.1" return "2.4.0"

View file

@ -1,10 +1,36 @@
"""Backup""" """Backup"""
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
import strawberry
from enum import Enum from enum import Enum
import strawberry
from pydantic import BaseModel
@strawberry.enum @strawberry.enum
class RestoreStrategy(Enum): class RestoreStrategy(Enum):
INPLACE = "INPLACE" INPLACE = "INPLACE"
DOWNLOAD_VERIFY_OVERWRITE = "DOWNLOAD_VERIFY_OVERWRITE" DOWNLOAD_VERIFY_OVERWRITE = "DOWNLOAD_VERIFY_OVERWRITE"
@strawberry.enum
class BackupReason(Enum):
EXPLICIT = "EXPLICIT"
AUTO = "AUTO"
PRE_RESTORE = "PRE_RESTORE"
class _AutobackupQuotas(BaseModel):
last: int
daily: int
weekly: int
monthly: int
yearly: int
@strawberry.experimental.pydantic.type(model=_AutobackupQuotas, all_fields=True)
class AutobackupQuotas:
pass
@strawberry.experimental.pydantic.input(model=_AutobackupQuotas, all_fields=True)
class AutobackupQuotasInput:
pass

View file

@ -11,7 +11,10 @@ from selfprivacy_api.graphql.queries.backup import BackupConfiguration
from selfprivacy_api.graphql.queries.backup import Backup from selfprivacy_api.graphql.queries.backup import Backup
from selfprivacy_api.graphql.queries.providers import BackupProvider from selfprivacy_api.graphql.queries.providers import BackupProvider
from selfprivacy_api.graphql.common_types.jobs import job_to_api_job from selfprivacy_api.graphql.common_types.jobs import job_to_api_job
from selfprivacy_api.graphql.common_types.backup import RestoreStrategy from selfprivacy_api.graphql.common_types.backup import (
AutobackupQuotasInput,
RestoreStrategy,
)
from selfprivacy_api.backup import Backups from selfprivacy_api.backup import Backups
from selfprivacy_api.services import get_service_by_id from selfprivacy_api.services import get_service_by_id
@ -90,6 +93,33 @@ class BackupMutations:
configuration=Backup().configuration(), configuration=Backup().configuration(),
) )
@strawberry.mutation(permission_classes=[IsAuthenticated])
def set_autobackup_quotas(
self, quotas: AutobackupQuotasInput
) -> GenericBackupConfigReturn:
"""
Set autobackup quotas.
Values <=0 for any timeframe mean no limits for that timeframe.
To disable autobackup use autobackup period setting, not this mutation.
"""
try:
Backups.set_autobackup_quotas(quotas)
return GenericBackupConfigReturn(
success=True,
message="",
code=200,
configuration=Backup().configuration(),
)
except Exception as e:
return GenericBackupConfigReturn(
success=False,
message=str(e),
code=400,
configuration=Backup().configuration(),
)
@strawberry.mutation(permission_classes=[IsAuthenticated]) @strawberry.mutation(permission_classes=[IsAuthenticated])
def start_backup(self, service_id: str) -> GenericJobMutationReturn: def start_backup(self, service_id: str) -> GenericJobMutationReturn:
"""Start backup""" """Start backup"""

View file

@ -13,6 +13,7 @@ from selfprivacy_api.graphql.common_types.service import (
SnapshotInfo, SnapshotInfo,
service_to_graphql_service, service_to_graphql_service,
) )
from selfprivacy_api.graphql.common_types.backup import AutobackupQuotas
from selfprivacy_api.services import get_service_by_id from selfprivacy_api.services import get_service_by_id
@ -26,6 +27,8 @@ class BackupConfiguration:
is_initialized: bool is_initialized: bool
# If none, autobackups are disabled # If none, autobackups are disabled
autobackup_period: typing.Optional[int] autobackup_period: typing.Optional[int]
# None is equal to all quotas being unlimited (-1). Optional for compatibility reasons.
autobackup_quotas: AutobackupQuotas
# Bucket name for Backblaze, path for some other providers # Bucket name for Backblaze, path for some other providers
location_name: typing.Optional[str] location_name: typing.Optional[str]
location_id: typing.Optional[str] location_id: typing.Optional[str]
@ -42,6 +45,7 @@ class Backup:
autobackup_period=Backups.autobackup_period_minutes(), autobackup_period=Backups.autobackup_period_minutes(),
location_name=Backups.provider().location, location_name=Backups.provider().location,
location_id=Backups.provider().repo_id, location_id=Backups.provider().repo_id,
autobackup_quotas=Backups.autobackup_quotas(),
) )
@strawberry.field @strawberry.field

View file

@ -1,8 +1,11 @@
import datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel
from selfprivacy_api.graphql.common_types.backup import BackupReason
class Snapshot(BaseModel): class Snapshot(BaseModel):
id: str id: str
service_name: str service_name: str
created_at: datetime.datetime created_at: datetime.datetime
reason: BackupReason

View file

@ -1,11 +1,14 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from enum import Enum
def store_model_as_hash(redis, redis_key, model): def store_model_as_hash(redis, redis_key, model):
for key, value in model.dict().items(): for key, value in model.dict().items():
if isinstance(value, datetime): if isinstance(value, datetime):
value = value.isoformat() value = value.isoformat()
if isinstance(value, Enum):
value = value.value
redis.hset(redis_key, key, str(value)) redis.hset(redis_key, key, str(value))

View file

@ -2,7 +2,7 @@ from setuptools import setup, find_packages
setup( setup(
name="selfprivacy_api", name="selfprivacy_api",
version="2.3.1", version="2.4.0",
packages=find_packages(), packages=find_packages(),
scripts=[ scripts=[
"selfprivacy_api/app.py", "selfprivacy_api/app.py",

View file

@ -4,6 +4,10 @@ from tests.common import generate_backup_query
from selfprivacy_api.graphql.common_types.service import service_to_graphql_service from selfprivacy_api.graphql.common_types.service import service_to_graphql_service
from selfprivacy_api.graphql.common_types.backup import (
_AutobackupQuotas,
AutobackupQuotas,
)
from selfprivacy_api.jobs import Jobs, JobStatus from selfprivacy_api.jobs import Jobs, JobStatus
API_RELOAD_SNAPSHOTS = """ API_RELOAD_SNAPSHOTS = """
@ -38,6 +42,34 @@ mutation TestAutobackupPeriod($period: Int) {
} }
""" """
API_SET_AUTOBACKUP_QUOTAS_MUTATION = """
mutation TestAutobackupQuotas($input: AutobackupQuotasInput!) {
backup {
setAutobackupQuotas(quotas: $input) {
success
message
code
configuration {
provider
encryptionKey
isInitialized
autobackupPeriod
locationName
locationId
autobackupQuotas {
last
daily
weekly
monthly
yearly
}
}
}
}
}
"""
API_REMOVE_REPOSITORY_MUTATION = """ API_REMOVE_REPOSITORY_MUTATION = """
mutation TestRemoveRepo { mutation TestRemoveRepo {
backup { backup {
@ -177,6 +209,17 @@ def api_set_period(authorized_client, period):
return response return response
def api_set_quotas(authorized_client, quotas: _AutobackupQuotas):
response = authorized_client.post(
"/graphql",
json={
"query": API_SET_AUTOBACKUP_QUOTAS_MUTATION,
"variables": {"input": quotas.dict()},
},
)
return response
def api_remove(authorized_client): def api_remove(authorized_client):
response = authorized_client.post( response = authorized_client.post(
"/graphql", "/graphql",
@ -323,6 +366,22 @@ def test_remove(authorized_client, generic_userdata):
assert configuration["isInitialized"] is False assert configuration["isInitialized"] is False
def test_autobackup_quotas_nonzero(authorized_client):
quotas = _AutobackupQuotas(
last=3,
daily=2,
weekly=4,
monthly=13,
yearly=14,
)
response = api_set_quotas(authorized_client, quotas)
data = get_data(response)["backup"]["setAutobackupQuotas"]
assert_ok(data)
configuration = data["configuration"]
assert configuration["autobackupQuotas"] == quotas
def test_autobackup_period_nonzero(authorized_client): def test_autobackup_period_nonzero(authorized_client):
new_period = 11 new_period = 11
response = api_set_period(authorized_client, new_period) response = api_set_period(authorized_client, new_period)

View file

@ -5,8 +5,12 @@ from os import makedirs
from os import remove from os import remove
from os import listdir from os import listdir
from os import urandom from os import urandom
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone, date, time
from subprocess import Popen from subprocess import Popen
from copy import copy
import secrets
import tempfile import tempfile
@ -17,11 +21,13 @@ from selfprivacy_api.services.service import ServiceStatus
from selfprivacy_api.services import get_service_by_id from selfprivacy_api.services import get_service_by_id
from selfprivacy_api.services.test_service import DummyService from selfprivacy_api.services.test_service import DummyService
from selfprivacy_api.graphql.queries.providers import BackupProvider from selfprivacy_api.graphql.queries.providers import BackupProvider
from selfprivacy_api.graphql.common_types.backup import RestoreStrategy from selfprivacy_api.graphql.common_types.backup import RestoreStrategy, BackupReason
from selfprivacy_api.jobs import Jobs, JobStatus from selfprivacy_api.jobs import Jobs, JobStatus
from selfprivacy_api.models.backup.snapshot import Snapshot from selfprivacy_api.models.backup.snapshot import Snapshot
from selfprivacy_api.graphql.common_types.backup import AutobackupQuotas
from selfprivacy_api.backup import Backups, BACKUP_PROVIDER_ENVS from selfprivacy_api.backup import Backups, BACKUP_PROVIDER_ENVS
import selfprivacy_api.backup.providers as providers import selfprivacy_api.backup.providers as providers
from selfprivacy_api.backup.providers import AbstractBackupProvider from selfprivacy_api.backup.providers import AbstractBackupProvider
@ -293,6 +299,367 @@ def test_backup_returns_snapshot(backups, dummy_service):
assert Backups.get_snapshot_by_id(snapshot.id) is not None assert Backups.get_snapshot_by_id(snapshot.id) is not None
assert snapshot.service_name == name assert snapshot.service_name == name
assert snapshot.created_at is not None assert snapshot.created_at is not None
assert snapshot.reason == BackupReason.EXPLICIT
def test_backup_reasons(backups, dummy_service):
snap = Backups.back_up(dummy_service, BackupReason.AUTO)
assert snap.reason == BackupReason.AUTO
Backups.force_snapshot_cache_reload()
snaps = Backups.get_snapshots(dummy_service)
assert snaps[0].reason == BackupReason.AUTO
unlimited_quotas = AutobackupQuotas(
last=-1,
daily=-1,
weekly=-1,
monthly=-1,
yearly=-1,
)
zero_quotas = AutobackupQuotas(
last=0,
daily=0,
weekly=0,
monthly=0,
yearly=0,
)
def test_get_empty_quotas(backups):
quotas = Backups.autobackup_quotas()
assert quotas is not None
assert quotas == unlimited_quotas
def test_set_quotas(backups):
quotas = AutobackupQuotas(
last=3,
daily=2343,
weekly=343,
monthly=0,
yearly=-34556,
)
Backups.set_autobackup_quotas(quotas)
assert Backups.autobackup_quotas() == AutobackupQuotas(
last=3,
daily=2343,
weekly=343,
monthly=0,
yearly=-1,
)
def test_set_zero_quotas(backups):
quotas = AutobackupQuotas(
last=0,
daily=0,
weekly=0,
monthly=0,
yearly=0,
)
Backups.set_autobackup_quotas(quotas)
assert Backups.autobackup_quotas() == zero_quotas
def test_set_unlimited_quotas(backups):
quotas = AutobackupQuotas(
last=-1,
daily=-1,
weekly=-1,
monthly=-1,
yearly=-1,
)
Backups.set_autobackup_quotas(quotas)
assert Backups.autobackup_quotas() == unlimited_quotas
def test_set_zero_quotas_after_unlimited(backups):
quotas = AutobackupQuotas(
last=-1,
daily=-1,
weekly=-1,
monthly=-1,
yearly=-1,
)
Backups.set_autobackup_quotas(quotas)
assert Backups.autobackup_quotas() == unlimited_quotas
quotas = AutobackupQuotas(
last=0,
daily=0,
weekly=0,
monthly=0,
yearly=0,
)
Backups.set_autobackup_quotas(quotas)
assert Backups.autobackup_quotas() == zero_quotas
def dummy_snapshot(date: datetime):
return Snapshot(
id=str(hash(date)),
service_name="someservice",
created_at=date,
reason=BackupReason.EXPLICIT,
)
def test_autobackup_snapshots_pruning(backups):
# Wednesday, fourth week
now = datetime(year=2023, month=1, day=25, hour=10)
snaps = [
dummy_snapshot(now),
dummy_snapshot(now - timedelta(minutes=5)),
dummy_snapshot(now - timedelta(hours=2)),
dummy_snapshot(now - timedelta(hours=5)),
dummy_snapshot(now - timedelta(days=1)),
dummy_snapshot(now - timedelta(days=1, hours=2)),
dummy_snapshot(now - timedelta(days=1, hours=3)),
dummy_snapshot(now - timedelta(days=2)),
dummy_snapshot(now - timedelta(days=7)),
dummy_snapshot(now - timedelta(days=12)),
dummy_snapshot(now - timedelta(days=23)),
dummy_snapshot(now - timedelta(days=28)),
dummy_snapshot(now - timedelta(days=32)),
dummy_snapshot(now - timedelta(days=47)),
dummy_snapshot(now - timedelta(days=64)),
dummy_snapshot(now - timedelta(days=84)),
dummy_snapshot(now - timedelta(days=104)),
dummy_snapshot(now - timedelta(days=365 * 2)),
]
old_len = len(snaps)
quotas = copy(unlimited_quotas)
Backups.set_autobackup_quotas(quotas)
assert Backups._prune_snaps_with_quotas(snaps) == snaps
quotas = copy(zero_quotas)
quotas.last = 2
quotas.daily = 2
Backups.set_autobackup_quotas(quotas)
snaps_to_keep = Backups._prune_snaps_with_quotas(snaps)
assert snaps_to_keep == [
dummy_snapshot(now),
dummy_snapshot(now - timedelta(minutes=5)),
# dummy_snapshot(now - timedelta(hours=2)),
# dummy_snapshot(now - timedelta(hours=5)),
dummy_snapshot(now - timedelta(days=1)),
# dummy_snapshot(now - timedelta(days=1, hours=2)),
# dummy_snapshot(now - timedelta(days=1, hours=3)),
# dummy_snapshot(now - timedelta(days=2)),
# dummy_snapshot(now - timedelta(days=7)),
# dummy_snapshot(now - timedelta(days=12)),
# dummy_snapshot(now - timedelta(days=23)),
# dummy_snapshot(now - timedelta(days=28)),
# dummy_snapshot(now - timedelta(days=32)),
# dummy_snapshot(now - timedelta(days=47)),
# dummy_snapshot(now - timedelta(days=64)),
# dummy_snapshot(now - timedelta(days=84)),
# dummy_snapshot(now - timedelta(days=104)),
# dummy_snapshot(now - timedelta(days=365 * 2)),
]
# checking that this function does not mutate the argument
assert snaps != snaps_to_keep
assert len(snaps) == old_len
quotas = copy(zero_quotas)
quotas.weekly = 4
Backups.set_autobackup_quotas(quotas)
snaps_to_keep = Backups._prune_snaps_with_quotas(snaps)
assert snaps_to_keep == [
dummy_snapshot(now),
# dummy_snapshot(now - timedelta(minutes=5)),
# dummy_snapshot(now - timedelta(hours=2)),
# dummy_snapshot(now - timedelta(hours=5)),
# dummy_snapshot(now - timedelta(days=1)),
# dummy_snapshot(now - timedelta(days=1, hours=2)),
# dummy_snapshot(now - timedelta(days=1, hours=3)),
# dummy_snapshot(now - timedelta(days=2)),
dummy_snapshot(now - timedelta(days=7)),
dummy_snapshot(now - timedelta(days=12)),
dummy_snapshot(now - timedelta(days=23)),
# dummy_snapshot(now - timedelta(days=28)),
# dummy_snapshot(now - timedelta(days=32)),
# dummy_snapshot(now - timedelta(days=47)),
# dummy_snapshot(now - timedelta(days=64)),
# dummy_snapshot(now - timedelta(days=84)),
# dummy_snapshot(now - timedelta(days=104)),
# dummy_snapshot(now - timedelta(days=365 * 2)),
]
quotas = copy(zero_quotas)
quotas.monthly = 7
Backups.set_autobackup_quotas(quotas)
snaps_to_keep = Backups._prune_snaps_with_quotas(snaps)
assert snaps_to_keep == [
dummy_snapshot(now),
# dummy_snapshot(now - timedelta(minutes=5)),
# dummy_snapshot(now - timedelta(hours=2)),
# dummy_snapshot(now - timedelta(hours=5)),
# dummy_snapshot(now - timedelta(days=1)),
# dummy_snapshot(now - timedelta(days=1, hours=2)),
# dummy_snapshot(now - timedelta(days=1, hours=3)),
# dummy_snapshot(now - timedelta(days=2)),
# dummy_snapshot(now - timedelta(days=7)),
# dummy_snapshot(now - timedelta(days=12)),
# dummy_snapshot(now - timedelta(days=23)),
dummy_snapshot(now - timedelta(days=28)),
# dummy_snapshot(now - timedelta(days=32)),
# dummy_snapshot(now - timedelta(days=47)),
dummy_snapshot(now - timedelta(days=64)),
# dummy_snapshot(now - timedelta(days=84)),
dummy_snapshot(now - timedelta(days=104)),
dummy_snapshot(now - timedelta(days=365 * 2)),
]
def test_autobackup_snapshots_pruning_yearly(backups):
snaps = [
dummy_snapshot(datetime(year=2055, month=3, day=1)),
dummy_snapshot(datetime(year=2055, month=2, day=1)),
dummy_snapshot(datetime(year=2023, month=4, day=1)),
dummy_snapshot(datetime(year=2023, month=3, day=1)),
dummy_snapshot(datetime(year=2023, month=2, day=1)),
dummy_snapshot(datetime(year=2021, month=2, day=1)),
]
quotas = copy(zero_quotas)
quotas.yearly = 2
Backups.set_autobackup_quotas(quotas)
snaps_to_keep = Backups._prune_snaps_with_quotas(snaps)
assert snaps_to_keep == [
dummy_snapshot(datetime(year=2055, month=3, day=1)),
dummy_snapshot(datetime(year=2023, month=4, day=1)),
]
def test_autobackup_snapshots_pruning_bottleneck(backups):
now = datetime(year=2023, month=1, day=25, hour=10)
snaps = [
dummy_snapshot(now),
dummy_snapshot(now - timedelta(minutes=5)),
dummy_snapshot(now - timedelta(hours=2)),
dummy_snapshot(now - timedelta(hours=3)),
dummy_snapshot(now - timedelta(hours=4)),
]
yearly_quota = copy(zero_quotas)
yearly_quota.yearly = 2
monthly_quota = copy(zero_quotas)
monthly_quota.monthly = 2
weekly_quota = copy(zero_quotas)
weekly_quota.weekly = 2
daily_quota = copy(zero_quotas)
daily_quota.daily = 2
last_quota = copy(zero_quotas)
last_quota.last = 1
last_quota.yearly = 2
for quota in [last_quota, yearly_quota, monthly_quota, weekly_quota, daily_quota]:
print(quota)
Backups.set_autobackup_quotas(quota)
snaps_to_keep = Backups._prune_snaps_with_quotas(snaps)
assert snaps_to_keep == [
dummy_snapshot(now),
# If there is a vacant quota, we should keep the last snapshot even if it doesn't fit
dummy_snapshot(now - timedelta(hours=4)),
]
def test_autobackup_snapshots_pruning_edgeweek(backups):
# jan 1 2023 is Sunday
snaps = [
dummy_snapshot(datetime(year=2023, month=1, day=6)),
dummy_snapshot(datetime(year=2023, month=1, day=1)),
dummy_snapshot(datetime(year=2022, month=12, day=31)),
dummy_snapshot(datetime(year=2022, month=12, day=30)),
]
quotas = copy(zero_quotas)
quotas.weekly = 2
Backups.set_autobackup_quotas(quotas)
snaps_to_keep = Backups._prune_snaps_with_quotas(snaps)
assert snaps_to_keep == [
dummy_snapshot(datetime(year=2023, month=1, day=6)),
dummy_snapshot(datetime(year=2023, month=1, day=1)),
]
def test_autobackup_snapshots_pruning_big_gap(backups):
snaps = [
dummy_snapshot(datetime(year=2023, month=1, day=6)),
dummy_snapshot(datetime(year=2023, month=1, day=2)),
dummy_snapshot(datetime(year=2022, month=10, day=31)),
dummy_snapshot(datetime(year=2022, month=10, day=30)),
]
quotas = copy(zero_quotas)
quotas.weekly = 2
Backups.set_autobackup_quotas(quotas)
snaps_to_keep = Backups._prune_snaps_with_quotas(snaps)
assert snaps_to_keep == [
dummy_snapshot(datetime(year=2023, month=1, day=6)),
dummy_snapshot(datetime(year=2022, month=10, day=31)),
]
def test_too_many_auto(backups, dummy_service):
assert Backups.autobackup_quotas()
quota = copy(zero_quotas)
quota.last = 2
Backups.set_autobackup_quotas(quota)
assert Backups.autobackup_quotas().last == 2
snap = Backups.back_up(dummy_service, BackupReason.AUTO)
assert len(Backups.get_snapshots(dummy_service)) == 1
snap2 = Backups.back_up(dummy_service, BackupReason.AUTO)
assert len(Backups.get_snapshots(dummy_service)) == 2
snap3 = Backups.back_up(dummy_service, BackupReason.AUTO)
assert len(Backups.get_snapshots(dummy_service)) == 2
snaps = Backups.get_snapshots(dummy_service)
assert snap2 in snaps
assert snap3 in snaps
assert snap not in snaps
quota.last = -1
Backups.set_autobackup_quotas(quota)
snap4 = Backups.back_up(dummy_service, BackupReason.AUTO)
snaps = Backups.get_snapshots(dummy_service)
assert len(snaps) == 3
assert snap4 in snaps
# Retroactivity
quota.last = 1
Backups.set_autobackup_quotas(quota)
snaps = Backups.get_snapshots(dummy_service)
assert len(snaps) == 1
snap5 = Backups.back_up(dummy_service, BackupReason.AUTO)
snaps = Backups.get_snapshots(dummy_service)
assert len(snaps) == 1
assert snap5 in snaps
# Explicit snaps are not affected
snap6 = Backups.back_up(dummy_service, BackupReason.EXPLICIT)
snaps = Backups.get_snapshots(dummy_service)
assert len(snaps) == 2
assert snap5 in snaps
assert snap6 in snaps
def folder_files(folder): def folder_files(folder):
@ -435,7 +802,10 @@ def test_forget_snapshot(backups, dummy_service):
def test_forget_nonexistent_snapshot(backups, dummy_service): def test_forget_nonexistent_snapshot(backups, dummy_service):
bogus = Snapshot( bogus = Snapshot(
id="gibberjibber", service_name="nohoho", created_at=datetime.now(timezone.utc) id="gibberjibber",
service_name="nohoho",
created_at=datetime.now(timezone.utc),
reason=BackupReason.EXPLICIT,
) )
with pytest.raises(ValueError): with pytest.raises(ValueError):
Backups.forget_snapshot(bogus) Backups.forget_snapshot(bogus)
@ -508,6 +878,8 @@ def test_restore_snapshot_task(
snaps = Backups.get_snapshots(dummy_service) snaps = Backups.get_snapshots(dummy_service)
if restore_strategy == RestoreStrategy.INPLACE: if restore_strategy == RestoreStrategy.INPLACE:
assert len(snaps) == 2 assert len(snaps) == 2
reasons = [snap.reason for snap in snaps]
assert BackupReason.PRE_RESTORE in reasons
else: else:
assert len(snaps) == 1 assert len(snaps) == 1