Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Call command on backup completion #53

Merged
merged 1 commit into from
Oct 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Call configured script on (scheduler triggered) backup completion
5 changes: 3 additions & 2 deletions doc/backy.conf.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
global:
base-dir: /my/backydir
worker-limit: 3
backup-completed-callback: /path/to/script.sh
schedules:
default:
daily:
Expand Down Expand Up @@ -37,8 +38,8 @@ jobs:
test03:
schedule: hourly
source:
type: flyingcircus,
type: flyingcircus
consul_acl_token: a211c244-846b-11e5-999b-081196cf15b4
vm: test03,
vm: test03
pool: test
image: test03.root
4 changes: 4 additions & 0 deletions doc/man-backy.rst
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,10 @@ config
Maximum number of concurrent processes spawned by the scheduler.
Defaults to 1 (no parallel backups).

backup-completed-callback
Command/Script to invoke after the scheduler successfully completed a backup.
The first argument is the job name. The output of `backy status --yaml` is available on stdin.

status-file
Path to a YAML status dump which is regularly updated by the scheduler
and evaluated by **backy check**. Defaults to `{base-dir}/status`.
Expand Down
5 changes: 4 additions & 1 deletion lib.nix
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,12 @@ in
checks = {
pytest = runCommand "pytest" {
nativeBuildInputs = [ poetryEnv ];
src = ./.;
} ''
unpackPhase
cd *-source
export BACKY_CMD=${poetryApplication}/bin/backy
cd ${./.}
patchShebangs src
pytest -vv -p no:cacheprovider --no-cov
touch $out
'';
Expand Down
2 changes: 2 additions & 0 deletions src/backy/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class BackyDaemon(object):
# config defaults, will be overriden from config file
worker_limit: int = 1
base_dir: str
backup_completed_callback: Optional[str]
status_file: str
status_interval: int = 30
telnet_addrs: str = "::1, 127.0.0.1"
Expand Down Expand Up @@ -63,6 +64,7 @@ def _read_config(self):
g = self.config.get("global", {})
self.worker_limit = int(g.get("worker-limit", type(self).worker_limit))
self.base_dir = g.get("base-dir")
self.backup_completed_callback = g.get("backup-completed-callback")
self.status_file = g.get("status-file", p.join(self.base_dir, "status"))
self.status_interval = int(
g.get("status-interval", type(self).status_interval)
Expand Down
11 changes: 8 additions & 3 deletions src/backy/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import humanize
import structlog
import tzlocal
import yaml
from prettytable import PrettyTable
from structlog.stdlib import BoundLogger

Expand Down Expand Up @@ -39,17 +40,20 @@ def __init__(self, path, log):
self.path = path
self.log = log

def status(self):
def status(self, yaml_: bool):
b = backy.backup.Backup(self.path, self.log)
if yaml_:
print(yaml.safe_dump([r.to_dict() for r in b.clean_history]))
return
total_bytes = 0

tz = tzlocal.get_localzone()
t = PrettyTable(
[f"Date ({tz})", "ID", "Size", "Duration", "Tags", "Trust"]
)
t.align = "l"
t.align["Size"] = "r"
t.align["Durat"] = "r"
t.align["Size"] = "r" # type: ignore
t.align["Durat"] = "r" # type: ignore

for r in b.history:
total_bytes += r.stats.get("bytes_written", 0)
Expand Down Expand Up @@ -209,6 +213,7 @@ def setup_argparser():
Show backup status. Show inventory and summary information.
""",
)
p.add_argument("--yaml", dest="yaml_", action="store_true")
p.set_defaults(func="status")

# upgrade
Expand Down
10 changes: 6 additions & 4 deletions src/backy/revision.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,12 @@ def materialize(self):

def write_info(self):
self.log.debug("writing-info", tags=", ".join(self.tags))
metadata = {
with SafeFile(self.info_filename, encoding="utf-8") as f:
f.open_new("wb")
yaml.safe_dump(self.to_dict(), f)

def to_dict(self):
return {
"uuid": self.uuid,
"backend_type": self.backend_type,
"timestamp": self.timestamp,
Expand All @@ -105,9 +110,6 @@ def write_info(self):
"trust": self.trust.value,
"tags": list(self.tags),
}
with SafeFile(self.info_filename, encoding="utf-8") as f:
f.open_new("wb")
yaml.safe_dump(metadata, f)

def distrust(self):
self.log.info("distrusted")
Expand Down
57 changes: 57 additions & 0 deletions src/backy/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ async def run_forever(self):
await self.run_backup(next_tags)
await self.run_expiry()
await self.run_purge()
await self.run_callback()
except asyncio.CancelledError:
raise
except Exception:
Expand Down Expand Up @@ -274,6 +275,62 @@ async def run_purge(self):
pass
raise

async def run_callback(self):
if not self.daemon.backup_completed_callback:
self.log.debug("callback-not-configured")
return

self.log.info("callback-started")
read, write = os.pipe()
backy_proc = await asyncio.create_subprocess_exec(
BACKY_CMD,
"-b",
self.path,
"-l",
self.logfile,
"status",
"--yaml",
stdin=subprocess.DEVNULL,
stdout=write,
stderr=subprocess.DEVNULL,
)
os.close(write)
callback_proc = await asyncio.create_subprocess_exec(
self.daemon.backup_completed_callback,
self.name,
stdin=read,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
os.close(read)
try:
stdout, stderr = await callback_proc.communicate()
return_code1 = await backy_proc.wait()
self.log.info(
"callback-finished",
return_code1=return_code1,
return_code2=callback_proc.returncode,
subprocess_pid1=backy_proc.pid,
subprocess_pid2=callback_proc.pid,
stdout=stdout.decode() if stdout else None,
stderr=stderr.decode() if stderr else None,
)
except asyncio.CancelledError:
self.log.warning(
"callback-cancelled",
subprocess_pid1=backy_proc.pid,
subprocess_pid2=callback_proc.pid,
)
try:
backy_proc.terminate()
dhnasa marked this conversation as resolved.
Show resolved Hide resolved
except ProcessLookupError:
pass
try:
callback_proc.terminate()
except ProcessLookupError:
pass
raise

def start(self):
assert self._task is None
self._task = self.daemon.loop.create_task(
Expand Down
3 changes: 3 additions & 0 deletions src/backy/tests/test_callback.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env bash

cat > "${1}.callback_stdin"
22 changes: 21 additions & 1 deletion src/backy/tests/test_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
import re
import signal
import time
from pathlib import Path
from unittest import mock

import pytest
import yaml

from backy import utils
from backy.backends.chunked import ChunkedFileBackend
Expand All @@ -31,6 +33,7 @@ async def daemon(tmpdir, event_loop, log):
base-dir: {base_dir}
status-interval: 1
telnet_port: 1234
backup-completed-callback: {Path(__file__).parent / "test_callback.sh"}
schedules:
default:
daily:
Expand Down Expand Up @@ -143,7 +146,23 @@ async def test_run_backup(daemon, log):
with backend.open("r") as f:
assert f.read() == b"I am your father, Luke!"

daemon.terminate()

async def test_run_callback(daemon, log):
job = daemon.jobs["test01"]

await job.run_backup({"manual:asdf"})
await job.run_callback()

with open("test01.callback_stdin", "r") as f:
r = yaml.safe_load(f)[0]
# see directory api before changing this
assert isinstance(r["uuid"], str)
assert isinstance(r["trust"], str)
assert isinstance(r["timestamp"], datetime.datetime)
assert isinstance(r["tags"][0], str)
assert isinstance(r["stats"]["bytes_written"], int)
assert isinstance(r["stats"]["duration"], float)
# assert isinstance(r["location"], str)


def test_spread(daemon):
Expand Down Expand Up @@ -295,6 +314,7 @@ async def failing_coroutine(*args, **kw):
monkeypatch.setattr(job, "_wait_for_deadline", null_coroutine)
monkeypatch.setattr(job, "run_expiry", null_coroutine)
monkeypatch.setattr(job, "run_purge", null_coroutine)
monkeypatch.setattr(job, "run_callback", null_coroutine)
monkeypatch.setattr(job, "run_backup", failing_coroutine)

# This patch causes a single run through the generator loop.
Expand Down
42 changes: 38 additions & 4 deletions src/backy/tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def test_call_status(capsys, backup, argv, monkeypatch):
Ellipsis(
"""\
(<backy.main.Command object at 0x...>,)
{}
{'yaml_': False}
"""
)
== out
Expand All @@ -97,7 +97,7 @@ def test_call_status(capsys, backup, argv, monkeypatch):
Ellipsis(
"""\
... D command/invoked args='... -v -b ... status'
... D command/parsed func='status' func_args={}
... D command/parsed func='status' func_args={'yaml_': False}
... D command/successful \n\
"""
)
Expand Down Expand Up @@ -241,7 +241,7 @@ def do_raise(*args, **kw):
Ellipsis(
"""\
... D command/invoked args='... -l ... -b ... status'
... D command/parsed func='status' func_args={}
... D command/parsed func='status' func_args={'yaml_': False}
... E command/failed exception_class='builtins.RuntimeError' exception_msg='test'
exception>\tTraceback (most recent call last):
exception>\t File ".../src/backy/main.py", line ..., in main
Expand All @@ -262,7 +262,7 @@ def test_commands_wrapper_status(backup, tmpdir, capsys, clock, tz_berlin, log):
revision.timestamp = backy.utils.now()
revision.materialize()

commands.status()
commands.status(yaml_=False)
out, err = capsys.readouterr()

assert err == ""
Expand All @@ -276,3 +276,37 @@ def test_commands_wrapper_status(backup, tmpdir, capsys, clock, tz_berlin, log):
1 revisions containing 0 Bytes data (estimated)
"""
)


def test_commands_wrapper_status_yaml(
backup, tmpdir, capsys, clock, tz_berlin, log
):
commands = backy.main.Command(str(tmpdir), log)

revision = Revision(backup, log, "1")
revision.timestamp = backy.utils.now()
revision.stats["duration"] = 3.5
revision.stats["bytes_written"] = 42
revision.materialize()
revision2 = Revision(backup, log, "2") # ignored
revision2.materialize()

commands.status(yaml_=True)
out, err = capsys.readouterr()

assert err == ""
assert (
out
== """\
- backend_type: chunked
parent: ''
stats:
bytes_written: 42
duration: 3.5
tags: []
timestamp: 2015-09-01 07:06:47+00:00
trust: trusted
uuid: '1'

"""
)
Loading