mirror of
https://git.selfprivacy.org/SelfPrivacy/selfprivacy-rest-api.git
synced 2025-03-12 01:23:49 +00:00
feature(backup): remember the reason for making a snapshot
This commit is contained in:
parent
36e915907f
commit
027a37bb47
8 changed files with 85 additions and 51 deletions
selfprivacy_api
backup
graphql/common_types
models/backup
tests/test_graphql
|
@ -23,7 +23,7 @@ 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
|
||||||
|
|
||||||
from selfprivacy_api.models.backup.snapshot import Snapshot
|
from selfprivacy_api.models.backup.snapshot import Snapshot
|
||||||
|
|
||||||
|
@ -264,10 +264,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 +280,10 @@ 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)
|
||||||
service.post_restore()
|
service.post_restore()
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
Jobs.update(job, status=JobStatus.ERROR)
|
Jobs.update(job, status=JobStatus.ERROR)
|
||||||
|
@ -306,7 +309,7 @@ class Backups:
|
||||||
snapshot: Snapshot,
|
snapshot: Snapshot,
|
||||||
job: Job,
|
job: Job,
|
||||||
) -> None:
|
) -> None:
|
||||||
failsafe_snapshot = Backups.back_up(service)
|
failsafe_snapshot = Backups.back_up(service, BackupReason.PRE_RESTORE)
|
||||||
|
|
||||||
Jobs.update(job, status=JobStatus.RUNNING)
|
Jobs.update(job, status=JobStatus.RUNNING)
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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]:
|
||||||
|
|
|
@ -12,6 +12,7 @@ from os.path import exists, join
|
||||||
from os import listdir
|
from os import listdir
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
|
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,7 @@ 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: List[str] = []) -> List[str]:
|
||||||
command = [
|
command = [
|
||||||
"restic",
|
"restic",
|
||||||
"-o",
|
"-o",
|
||||||
|
@ -94,13 +95,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
|
||||||
|
@ -164,7 +166,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
|
||||||
"""
|
"""
|
||||||
|
@ -173,33 +180,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: ",
|
||||||
|
@ -210,13 +219,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) -> Snapshot:
|
||||||
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:
|
||||||
|
@ -232,16 +241,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",
|
||||||
|
@ -475,11 +474,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)
|
||||||
|
|
|
@ -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
|
||||||
|
@ -22,11 +22,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
|
||||||
|
|
||||||
|
|
||||||
|
@ -49,4 +51,4 @@ 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)
|
||||||
|
|
|
@ -8,3 +8,10 @@ from enum import 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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -14,7 +14,7 @@ from selfprivacy_api.services import Service, get_all_services
|
||||||
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
|
||||||
|
@ -428,7 +428,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)
|
||||||
|
|
Loading…
Add table
Reference in a new issue