The BackupHelper extension API: how a consuming repo adds app-specific backup logic without changing the engine — Source plugins (auto-discovered via entry points) and lifecycle hooks (opt-in phases the runner invokes).
The engine knows how to move bytes safely — hash, bundle deterministically,
encrypt, apply retention, upload, notify, restore. The consuming repo knows what
the bytes mean. The core therefore never imports an application SDK;
app-specific behaviour (an n8n CLI export, a NocoDB REST export, quiescing a
service, an ENCRYPTION_KEY cross-check before a destructive restore) lives in
the repo as a Source plugin or a lifecycle hook, never inside this engine.
A source answers one question: what to capture. It dumps one backend into a staging directory and returns the artifacts it produced; the engine does everything else.
backuphelper.sources.base.Source is an ABC:
class Source(ABC):
type: ClassVar[str] = "" # the config discriminator
def __init__(self, spec: Mapping[str, Any]): ...
@abstractmethod
def produce(self, staging_dir: Path) -> list[StagedComponent]: ...
def restore(self, staged_dir: Path) -> None: # optional
raise NotImplementedError(f"{self.type} source does not support restore")typeis the string used in config ({"type": "nocodb", ...}) and the entry-point name.- The constructor receives the source's config
spec; the whole spec dict is kept onself.spec(config specs are open — extra keys are preserved — so a plugin validates its own fields, e.g. with a Pydantic model). produce(staging_dir)writes files intostaging_dirand returns a list ofStagedComponent. Report a failure by returning a component witherror=set andpath=Nonerather than raising, so one bad source degrades the job to a partial snapshot instead of aborting it.restore(staged_dir)is optional. Omit it and the base raisesNotImplementedError; implement it for sources that can be restored.
StagedComponent is a dataclass:
@dataclass
class StagedComponent:
name: str # component name (also its restore key)
kind: str # usually == self.type
path: Optional[Path] # the staged file, or None on failure
metadata: dict = field(default_factory=dict)
error: Optional[str] = NoneRaise backuphelper.sources.base.SourceError from restore (or from produce
if you must fail hard) to signal a source-level failure.
A plugin package that adds a nocodb source backing up a NocoDB base via its
REST API.
1 — the source class (myapp_backup/nocodb_source.py):
from __future__ import annotations
from pathlib import Path
from backuphelper.sources.base import Source, StagedComponent, SourceError
class NocoDBSource(Source):
type = "nocodb"
def produce(self, staging_dir: Path) -> list[StagedComponent]:
staging_dir.mkdir(parents=True, exist_ok=True)
out = staging_dir / "nocodb.json"
try:
# self.spec holds the config keys from the job's source entry.
data = _export_via_rest(self.spec["base_url"], self.spec["token"])
except Exception as exc: # degrade to a partial snapshot, don't abort
return [StagedComponent(name="nocodb", kind=self.type, path=None,
error=f"nocodb export failed: {exc}")]
out.write_text(data, encoding="utf-8")
return [StagedComponent(name="nocodb", kind=self.type, path=out,
metadata={"base_url": self.spec["base_url"]})]
def restore(self, staged_dir: Path) -> None:
payload = (Path(staged_dir) / "nocodb.json").read_text(encoding="utf-8")
try:
_import_via_rest(self.spec["base_url"], self.spec["token"], payload)
except Exception as exc:
raise SourceError(f"nocodb restore failed: {exc}") from exc2 — register it under the backuphelper.sources entry-point group in the
plugin package's own pyproject.toml:
[project.entry-points."backuphelper.sources"]
nocodb = "myapp_backup.nocodb_source:NocoDBSource"3 — install it into the image in the repo's meta-Dockerfile:
FROM ghcr.io/bauer-group/cs-backuphelper/backuphelper:latest
USER root
COPY myapp_backup/ /opt/myapp_backup/myapp_backup/
COPY pyproject.toml /opt/myapp_backup/
RUN pip install --no-cache-dir /opt/myapp_backup
USER backupDiscovery is by installed distribution metadata, not by import path. The registry reads entry points with
importlib.metadata.entry_points(group="backuphelper.sources"), so the plugin must bepip installed (which registers the entry point) — merely dropping a file ontoPYTHONPATHwill not register it.
4 — use it in the job config (env or BACKUP_CONFIG_JSON):
{ "type": "nocodb", "base_url": "http://nocodb:8080", "token": "${NOCODB_TOKEN}" }backuphelper.plugins.registry resolves a source type to a class:
ENTRY_POINT_GROUP = "backuphelper.sources".get_source_class(type_name)returns a built-in if the name matches one, otherwise loads plugins from the entry-point group.build_source(spec)constructs the resolved class from the spec.- Built-ins win over plugins of the same name. The built-in names are
postgres,mariadb,mysql,s3,filesystem,env— do not shadow these; register your plugin under a distincttype. - A plugin that fails to load is skipped, not fatal: a broken third-party plugin cannot break discovery of the others.
A Source plugin says what to capture; a command plugin adds app-specific
operator subcommands under the engine CLI — for restores the generic engine
cannot express (NocoDB's schema/record/attachment reimport, n8n's import:*).
The engine mounts them without being forked.
1 — build a typer.Typer group. Each @app.command becomes a subcommand:
import typer
app = typer.Typer(name="nocodb", help="NocoDB REST-API restore commands.")
@app.command("restore-schema")
def restore_schema(snapshot_id: str, force: bool = typer.Option(False, "--force")):
...2 — register it under the backuphelper.commands entry-point group. The
entry point may resolve to the Typer directly or to a zero-arg factory that
returns one:
[project.entry-points."backuphelper.commands"]
nocodb = "nocodb_backup_ext.commands:app"3 — use it. After the plugin is pip installed, the engine discovers and
mounts the group at CLI startup, so it appears as a nested command:
backuphelper nocodb restore-schema <snapshot-id> --base MyBasebackuphelper.plugins.commands.register_command_plugins(app) runs once at the
end of cli.py, after the built-in commands:
ENTRY_POINT_GROUP = "backuphelper.commands"; discovery is by installed distribution metadata (pip installed), exactly like Source plugins.- A group name collision with a built-in command is up to the plugin to avoid; pick a distinct top-level name (e.g. the app name).
- A plugin that fails to import, whose factory raises, or that is not a
typer.Typeris skipped with a warning — a broken command plugin never breaks the built-in CLI.
A command plugin typically pairs with a Source plugin in the same package: the
Source writes the app export into the snapshot, the command group reads it back
out. Reuse the engine's restore front-half (off-site S3 hydration, the sha256
gate, decrypt, extract_bundle) instead of reimplementing it — see the NocoDB
plugin's _snapshot.open_export for the pattern.
Hooks are opt-in extension points the runner invokes around a job. The zero-coupling online dump is the default — with no hooks registered, nothing runs. A repo registers hooks to quiesce an app, run a pre-restore safety gate, or do post-restore cleanup.
backuphelper.plugins.hooks.PHASES defines six phases:
| Phase | When |
|---|---|
pre_backup |
before a job produces any source |
post_backup |
after a job finishes (context carries the final status) |
pre_dump |
reserved dump-level phase |
post_dump |
reserved dump-level phase |
pre_restore |
before any component is restored (a gate) |
post_restore |
after all components are restored |
The runner currently invokes pre_backup / post_backup around run_job and
pre_restore / post_restore around restore_snapshot; pre_dump / post_dump
are defined phases reserved for finer dump-level wiring.
HookRegistry.run(phase, context) calls each registered hook and does not
swallow exceptions — a raising hook propagates. So a pre_* hook is a gate: if
it raises, the operation stops before any destructive work. The canonical use is
a pre_restore ENCRYPTION_KEY cross-check that refuses to restore an archive
encrypted under a different key:
import os
from backuphelper.plugins.hooks import HookRegistry
def guard_encryption_key(context) -> None:
# Abort the restore unless the current key matches the one recorded at backup.
current = os.environ.get("ENCRYPTION_KEY", "")
expected = _key_fingerprint_from_manifest(context["snapshot_id"])
if _fingerprint(current) != expected:
raise RuntimeError(
"ENCRYPTION_KEY does not match the snapshot's key — restore aborted"
)
registry = HookRegistry()
registry.register("pre_restore", guard_encryption_key)Because pre_restore fires before the per-component restore loop, a raise here
stops the restore before it overwrites any live data.
A repo ships a register(registry) function and points an entry point at it:
# my_plugin/hooks.py
def register(registry) -> None: # called once at CLI startup
registry.register("pre_restore", guard_encryption_key)
registry.register("pre_backup", quiesce_app)[project.entry-points."backuphelper.hooks"]
myapp = "my_plugin.hooks:register"backuphelper.plugins.hooks.discover_hooks() builds a HookRegistry from every
discovered register at startup and the CLI threads it into run_job /
restore_snapshot (create, the scheduler daemon, and restore). With no hook
plugins installed the registry is empty, so the zero-coupling online dump is
unchanged. A plugin whose register raises is skipped, not fatal.
The pre_restore / post_restore context carries the extracted snapshot dir and
manifest ({"job", "snapshot_id", "extracted", "manifest"}), so a gate hook can
read a captured artifact — e.g. compare the snapshot's env.json ENCRYPTION_KEY
to the live one and raise to abort. (You can still build and pass a HookRegistry
programmatically to run_job/restore_snapshot if you drive the runner directly.)
- deployment.md — the meta-Dockerfile that installs a plugin.
- migration.md — which fleet repos need a plugin vs. plain config.
- ../README.md — built-in sources and configuration layers.