feat(v2): complete v2 reimplementation

This commit is contained in:
2026-07-31 13:33:39 +02:00
parent 396219e776
commit bd107d6a30
137 changed files with 20737 additions and 155 deletions
@@ -0,0 +1,29 @@
"""bind repositories to manifest signing public keys
Revision ID: 0004_repository_signing_keys
Revises: 0003_execution_events
"""
import sqlalchemy as sa
from alembic import op
revision = "0004_repository_signing_keys"
down_revision = "0003_execution_events"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("repositories") as batch:
batch.add_column(
sa.Column("signing_key_id", sa.String(length=64), nullable=False, server_default="")
)
batch.add_column(
sa.Column("signing_public_key", sa.String(length=64), nullable=False, server_default="")
)
def downgrade() -> None:
with op.batch_alter_table("repositories") as batch:
batch.drop_column("signing_public_key")
batch.drop_column("signing_key_id")
@@ -0,0 +1,23 @@
"""persist restore dry-run intent
Revision ID: 0005_restore_dry_run
Revises: 0004_repository_signing_keys
"""
import sqlalchemy as sa
from alembic import op
revision = "0005_restore_dry_run"
down_revision = "0004_repository_signing_keys"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("restores") as batch:
batch.add_column(sa.Column("dry_run", sa.Boolean(), nullable=False, server_default="0"))
def downgrade() -> None:
with op.batch_alter_table("restores") as batch:
batch.drop_column("dry_run")
@@ -0,0 +1,43 @@
"""restrict persisted sources to local
Revision ID: 0006_local_sources_only
Revises: 0005_restore_dry_run
"""
import sqlalchemy as sa
from alembic import op
revision = "0006_local_sources_only"
down_revision = "0005_restore_dry_run"
branch_labels = None
depends_on = None
def _reject_nonlocal_sources() -> None:
connection = op.get_bind()
sources = sa.table("sources", sa.column("kind"))
count = connection.scalar(
sa.select(sa.func.count()).select_from(sources).where(sources.c.kind != "local")
)
if count is None:
raise RuntimeError("Cannot inspect persisted source kinds before migration.")
if count:
raise RuntimeError(
"Cannot restrict sources to local: "
f"found {count} non-local source row(s). Remove or migrate them before upgrading."
)
def upgrade() -> None:
_reject_nonlocal_sources()
with op.batch_alter_table("sources") as batch:
batch.drop_constraint(op.f("ck_sources_kind"), type_="check")
batch.create_check_constraint(op.f("ck_sources_kind"), "kind = 'local'")
def downgrade() -> None:
with op.batch_alter_table("sources") as batch:
batch.drop_constraint(op.f("ck_sources_kind"), type_="check")
batch.create_check_constraint(
op.f("ck_sources_kind"), "kind IN ('local','sftp','postgresql','mysql')"
)
@@ -0,0 +1,87 @@
"""add repository data key epochs
Revision ID: 0007_repository_data_key_epochs
Revises: 0006_local_sources_only
"""
import sqlalchemy as sa
from alembic import op
revision = "0007_repository_data_key_epochs"
down_revision = "0006_local_sources_only"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("repositories", sa.Column("active_data_key_id", sa.String(36), nullable=True))
op.add_column("backups", sa.Column("data_key_id", sa.String(36), nullable=True))
op.create_table(
"repository_data_key_epochs",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"repository_id",
sa.String(36),
sa.ForeignKey("repositories.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("key_id", sa.String(36), nullable=False),
sa.Column("state", sa.String(16), nullable=False),
sa.Column("retired_at", sa.DateTime(timezone=True)),
sa.UniqueConstraint(
"repository_id", "key_id", name="uq_repository_data_key_epochs_repository_key_epoch"
),
sa.CheckConstraint(
"state IN ('active','retired')",
name="ck_repository_data_key_epochs_repository_data_key_epoch_state",
),
)
op.create_index(
"ix_repository_data_key_epochs_repository_id",
"repository_data_key_epochs",
["repository_id"],
)
op.create_index(
"uq_repository_data_key_epochs_active",
"repository_data_key_epochs",
["repository_id"],
unique=True,
sqlite_where=sa.text("state = 'active'"),
)
def downgrade() -> None:
connection = op.get_bind()
epochs = sa.table("repository_data_key_epochs")
repositories = sa.table("repositories", sa.column("active_data_key_id"))
backups = sa.table("backups", sa.column("data_key_id"))
epoch_count = connection.scalar(sa.select(sa.func.count()).select_from(epochs))
active_key_count = connection.scalar(
sa.select(sa.func.count())
.select_from(repositories)
.where(repositories.c.active_data_key_id.is_not(None))
)
backup_key_count = connection.scalar(
sa.select(sa.func.count()).select_from(backups).where(backups.c.data_key_id.is_not(None))
)
if epoch_count or active_key_count or backup_key_count:
raise RuntimeError("cannot downgrade while repository data key metadata exists")
op.drop_index("uq_repository_data_key_epochs_active", table_name="repository_data_key_epochs")
op.drop_index(
"ix_repository_data_key_epochs_repository_id", table_name="repository_data_key_epochs"
)
op.drop_table("repository_data_key_epochs")
op.drop_column("backups", "data_key_id")
op.drop_column("repositories", "active_data_key_id")
@@ -0,0 +1,445 @@
"""replace notification attempt stub with a durable M12 outbox
Revision ID: 0008_notification_outbox
Revises: 0007_repository_data_key_epochs
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
import sqlalchemy as sa
from alembic import op
from backup_tool.ids import new_uuid7
revision = "0008_notification_outbox"
down_revision = "0007_repository_data_key_epochs"
branch_labels = None
depends_on = None
def _now() -> datetime:
return datetime.now(UTC)
def _parameterized_execute(connection: sa.Connection, statement: object) -> Any:
"""Execute only SQLAlchemy Core statements, never dynamic SQL strings."""
database = connection.execution_options()
return database.execute(statement) # type: ignore[arg-type]
def upgrade() -> None:
connection = op.get_bind()
# SQLite batch-rebuilds notification_subscriptions. Its old delivery table
# references this table, so enforcement must be suspended for this migration
# only while the legacy rows are copied into the replacement outbox shape.
if connection.dialect.name == "sqlite":
connection.exec_driver_sql("PRAGMA foreign_keys=OFF")
# Extend the existing subscription rows first: pre-M12 rows stay disabled until
# an operator explicitly configures a new credential/key.
with op.batch_alter_table("notification_subscriptions") as batch:
batch.add_column(
sa.Column("rate_limit_per_minute", sa.Integer(), nullable=False, server_default="60")
)
batch.add_column(sa.Column("rate_tokens", sa.Float(), nullable=False, server_default="60"))
batch.add_column(sa.Column("rate_updated_at", sa.DateTime(timezone=True), nullable=True))
batch.add_column(sa.Column("revision", sa.Integer(), nullable=False, server_default="1"))
batch.create_check_constraint("rate_positive", "rate_limit_per_minute > 0")
batch.create_check_constraint("rate_tokens_nonnegative", "rate_tokens >= 0")
batch.create_check_constraint("revision_positive", "revision > 0")
op.create_table(
"notification_events",
sa.Column("type", sa.String(96), nullable=False),
sa.Column("schema_version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("correlation_id", sa.String(36), nullable=False),
sa.Column("severity", sa.String(16), nullable=False),
sa.Column("resource_refs", sa.JSON(), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("canonical_envelope", sa.Text(), nullable=False),
sa.Column("deduplication_key", sa.String(255), nullable=True),
sa.Column("id", sa.String(36), primary_key=True),
sa.CheckConstraint("schema_version = 1", name="ck_notification_events_schema_version"),
sa.CheckConstraint(
"severity IN ('info','warning','error','security')",
name="ck_notification_events_severity",
),
sa.UniqueConstraint("deduplication_key", name="uq_notification_events_deduplication_key"),
)
op.create_index(
"ix_notification_events_type_occurred", "notification_events", ["type", "occurred_at"]
)
# Preserve unexpected rows from the unused baseline shape. Renaming first
# keeps the original data intact if an upgrade is interrupted before copy.
op.rename_table("notification_deliveries", "notification_deliveries_legacy")
op.create_table(
"notification_deliveries",
sa.Column(
"event_id",
sa.String(36),
sa.ForeignKey("notification_events.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column(
"subscription_id",
sa.String(36),
sa.ForeignKey("notification_subscriptions.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("state", sa.String(32), nullable=False, server_default="pending"),
sa.Column("due_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("lease_owner", sa.String(255), nullable=True),
sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("terminal_reason", sa.String(96), nullable=True),
sa.Column("response_class", sa.String(64), nullable=True),
sa.Column("response_summary", sa.String(512), nullable=True),
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.CheckConstraint(
"attempt_count >= 0", name="ck_notification_deliveries_attempt_count_nonnegative"
),
sa.CheckConstraint(
"state IN ('pending','leased','delivered','retry','failed')",
name="ck_notification_deliveries_state",
),
sa.UniqueConstraint(
"event_id", "subscription_id", name="uq_notification_deliveries_event_subscription"
),
)
op.create_index(
"ix_notification_deliveries_due", "notification_deliveries", ["state", "due_at"]
)
op.create_index(
"ix_notification_deliveries_lease", "notification_deliveries", ["state", "lease_expires_at"]
)
op.create_table(
"notification_delivery_attempts",
sa.Column(
"delivery_id",
sa.String(36),
sa.ForeignKey("notification_deliveries.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("number", sa.Integer(), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("outcome", sa.String(32), nullable=False, server_default="started"),
sa.Column("response_class", sa.String(64), nullable=True),
sa.Column("diagnostic", sa.String(512), nullable=True),
sa.Column("id", sa.String(36), primary_key=True),
sa.CheckConstraint("number > 0", name="ck_notification_delivery_attempts_number_positive"),
sa.CheckConstraint(
"outcome IN ('started','delivered','retry','failed')",
name="ck_notification_delivery_attempts_outcome",
),
sa.UniqueConstraint(
"delivery_id", "number", name="uq_notification_delivery_attempts_delivery_number"
),
)
op.create_index(
"ix_notification_attempts_delivery",
"notification_delivery_attempts",
["delivery_id", "number"],
)
legacy = sa.table(
"notification_deliveries_legacy",
sa.column("id"),
sa.column("event_id"),
sa.column("subscription_id"),
sa.column("attempt"),
sa.column("state"),
sa.column("response_class"),
sa.column("next_attempt_at"),
sa.column("created_at"),
sa.column("updated_at"),
)
rows = _parameterized_execute(connection, sa.select(legacy)).mappings().all()
events = sa.table(
"notification_events",
sa.column("id"),
sa.column("type"),
sa.column("schema_version"),
sa.column("occurred_at"),
sa.column("correlation_id"),
sa.column("severity"),
sa.column("resource_refs", sa.JSON()),
sa.column("payload", sa.JSON()),
sa.column("canonical_envelope"),
sa.column("deduplication_key"),
)
deliveries = sa.table(
"notification_deliveries",
*[
sa.column(name)
for name in (
"id",
"event_id",
"subscription_id",
"state",
"due_at",
"attempt_count",
"terminal_reason",
"response_class",
"response_summary",
"created_at",
"updated_at",
)
],
)
attempts = sa.table(
"notification_delivery_attempts",
*[
sa.column(name)
for name in (
"id",
"delivery_id",
"number",
"started_at",
"completed_at",
"outcome",
"response_class",
"diagnostic",
)
],
)
for row in rows:
occurred = row["created_at"] or _now()
event_id, delivery_id, attempt_id = (str(new_uuid7()), str(new_uuid7()), str(new_uuid7()))
payload = {"legacy_event_id": str(row["event_id"]), "legacy_delivery_id": str(row["id"])}
envelope = {
"event_schema_version": 1,
"id": event_id,
"type": "notification.legacy",
"occurred_at": occurred.isoformat()
if hasattr(occurred, "isoformat")
else str(occurred),
"correlation_id": event_id,
"severity": "warning",
"resource": {"subscription_id": str(row["subscription_id"])},
"payload": payload,
}
_parameterized_execute(
connection,
events.insert().values(
id=event_id,
type="notification.legacy",
schema_version=1,
occurred_at=occurred,
correlation_id=event_id,
severity="warning",
resource_refs=envelope["resource"],
payload=payload,
canonical_envelope=json.dumps(envelope, sort_keys=True, separators=(",", ":")),
deduplication_key=f"legacy:{row['id']}",
),
)
old_state = str(row["state"])
new_state = "retry" if old_state == "pending" else old_state
try:
legacy_attempt = max(1, int(row["attempt"]))
except (TypeError, ValueError) as error:
raise RuntimeError("legacy notification delivery has an invalid attempt") from error
_parameterized_execute(
connection,
deliveries.insert().values(
id=delivery_id,
event_id=event_id,
subscription_id=row["subscription_id"],
state=new_state,
due_at=row["next_attempt_at"] or occurred,
attempt_count=legacy_attempt,
terminal_reason="legacy_migrated" if new_state == "failed" else None,
response_class=row["response_class"],
response_summary="legacy delivery migrated",
created_at=occurred,
updated_at=row["updated_at"] or occurred,
),
)
_parameterized_execute(
connection,
attempts.insert().values(
id=attempt_id,
delivery_id=delivery_id,
number=legacy_attempt,
started_at=occurred,
completed_at=row["updated_at"] if new_state in {"delivered", "failed"} else None,
outcome="delivered"
if new_state == "delivered"
else ("failed" if new_state == "failed" else "retry"),
response_class=row["response_class"],
diagnostic="legacy delivery migrated",
),
)
op.drop_table("notification_deliveries_legacy")
op.create_table(
"notification_signing_keys",
sa.Column(
"subscription_id",
sa.String(36),
sa.ForeignKey("notification_subscriptions.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column(
"secret_id",
sa.String(36),
sa.ForeignKey("secrets.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("state", sa.String(16), nullable=False, server_default="active"),
sa.Column("overlap_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.CheckConstraint("version > 0", name="ck_notification_signing_keys_version_positive"),
sa.CheckConstraint(
"state IN ('active','overlap','retired')", name="ck_notification_signing_keys_state"
),
sa.UniqueConstraint(
"subscription_id", "version", name="uq_notification_signing_keys_subscription_version"
),
)
op.create_index(
"ix_notification_signing_keys_subscription",
"notification_signing_keys",
["subscription_id", "state"],
)
op.create_index(
"uq_notification_signing_keys_active",
"notification_signing_keys",
["subscription_id"],
unique=True,
sqlite_where=sa.text("state = 'active'"),
)
op.create_index(
"uq_notification_signing_keys_overlap",
"notification_signing_keys",
["subscription_id"],
unique=True,
sqlite_where=sa.text("state = 'overlap'"),
)
op.create_table(
"notification_email_settings",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("host", sa.String(255), nullable=False),
sa.Column("port", sa.Integer(), nullable=False, server_default="587"),
sa.Column("username", sa.String(255), nullable=False),
sa.Column(
"password_secret_id",
sa.String(36),
sa.ForeignKey("secrets.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("sender", sa.String(320), nullable=False),
sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="5"),
sa.Column("rate_limit_per_minute", sa.Integer(), nullable=False, server_default="60"),
sa.CheckConstraint("id = 1", name="ck_notification_email_settings_singleton"),
sa.CheckConstraint("port > 0 AND port < 65536", name="ck_notification_email_settings_port"),
sa.CheckConstraint("max_attempts > 0", name="ck_notification_email_settings_max_attempts"),
sa.CheckConstraint(
"rate_limit_per_minute > 0", name="ck_notification_email_settings_rate_positive"
),
)
if connection.dialect.name == "sqlite":
connection.exec_driver_sql("PRAGMA foreign_keys=ON")
def downgrade() -> None:
bind = op.get_bind()
for table in (
"notification_events",
"notification_delivery_attempts",
"notification_signing_keys",
"notification_email_settings",
):
if bind.scalar(sa.text(f"SELECT count(*) FROM {table}")):
raise RuntimeError(
"cannot downgrade while M12 notification history or configuration exists"
)
# A fresh M12 schema can safely return to the historical stub shape.
op.drop_table("notification_email_settings")
op.drop_index("uq_notification_signing_keys_overlap", table_name="notification_signing_keys")
op.drop_index("uq_notification_signing_keys_active", table_name="notification_signing_keys")
op.drop_index(
"ix_notification_signing_keys_subscription", table_name="notification_signing_keys"
)
op.drop_table("notification_signing_keys")
op.drop_index("ix_notification_attempts_delivery", table_name="notification_delivery_attempts")
op.drop_table("notification_delivery_attempts")
op.drop_index("ix_notification_deliveries_lease", table_name="notification_deliveries")
op.drop_index("ix_notification_deliveries_due", table_name="notification_deliveries")
op.drop_table("notification_deliveries")
op.drop_index("ix_notification_events_type_occurred", table_name="notification_events")
op.drop_table("notification_events")
op.create_table(
"notification_deliveries",
sa.Column("event_id", sa.String(36), nullable=False),
sa.Column("subscription_id", sa.String(36), nullable=False),
sa.Column("attempt", sa.Integer(), nullable=False),
sa.Column("state", sa.String(32), nullable=False),
sa.Column("response_class", sa.String(64)),
sa.Column("next_attempt_at", sa.DateTime(timezone=True)),
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.CheckConstraint("attempt > 0", name="ck_notification_deliveries_attempt_positive"),
sa.CheckConstraint(
"state IN ('pending','delivered','retry','failed')",
name="ck_notification_deliveries_state",
),
sa.ForeignKeyConstraint(
["subscription_id"], ["notification_subscriptions.id"], ondelete="RESTRICT"
),
sa.UniqueConstraint("event_id", "subscription_id", "attempt", name="uq_delivery_attempt"),
)
op.create_index(
"ix_notification_deliveries_state_next",
"notification_deliveries",
["state", "next_attempt_at"],
)
with op.batch_alter_table("notification_subscriptions") as batch:
batch.drop_constraint("revision_positive", type_="check")
batch.drop_constraint("rate_tokens_nonnegative", type_="check")
batch.drop_constraint("rate_positive", type_="check")
batch.drop_column("revision")
batch.drop_column("rate_updated_at")
batch.drop_column("rate_tokens")
batch.drop_column("rate_limit_per_minute")
@@ -0,0 +1,43 @@
"""allow staged SSH source definitions
Revision ID: 0009_allow_ssh_sources
Revises: 0008_notification_outbox
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0009_allow_ssh_sources"
down_revision = "0008_notification_outbox"
branch_labels = None
depends_on = None
def _reject_ssh_sources() -> None:
connection = op.get_bind()
sources = sa.table("sources", sa.column("kind"))
count = connection.scalar(
sa.select(sa.func.count()).select_from(sources).where(sources.c.kind == "ssh")
)
if count is None:
raise RuntimeError("Cannot inspect persisted SSH sources before migration.")
if count:
raise RuntimeError(
"Cannot restrict sources to local: "
f"found {count} SSH source row(s). Remove them before downgrading."
)
def upgrade() -> None:
with op.batch_alter_table("sources") as batch:
batch.drop_constraint(op.f("ck_sources_kind"), type_="check")
batch.create_check_constraint(op.f("ck_sources_kind"), "kind IN ('local','ssh')")
def downgrade() -> None:
_reject_ssh_sources()
with op.batch_alter_table("sources") as batch:
batch.drop_constraint(op.f("ck_sources_kind"), type_="check")
batch.create_check_constraint(op.f("ck_sources_kind"), "kind = 'local'")
+1 -1
View File
@@ -18,9 +18,9 @@ dependencies = [
"cryptography==49.0.0",
"fastapi==0.136.1",
"httpx==0.28.1",
"paramiko==5.0.0",
"pydantic==2.13.4",
"pydantic-settings==2.14.2",
"paramiko==5.0.0",
"sqlalchemy[asyncio]==2.0.49",
"uvicorn[standard]==0.51.0",
]
+74 -15
View File
@@ -1,21 +1,43 @@
from __future__ import annotations
import os
import stat
from collections.abc import AsyncIterator
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
from backup_tool.config import Settings
class SourceError(ValueError):
pass
"""A redacted source failure with a stable execution reason."""
def __init__(self, message: str, *, reason_code: str = "source_invalid") -> None:
super().__init__(message)
self.reason_code = reason_code
@dataclass(frozen=True)
class Entry:
path: str
kind: str
size: int | None = None
size: int
mode: int
mtime_ns: int
link_target: str | None = None
class SourceReader(Protocol):
def validate_config(self) -> None: ...
async def probe(self) -> dict[str, int]: ...
def enumerate_entries(self) -> AsyncIterator[Entry]: ...
def open_content(self, path: str) -> AsyncIterator[bytes]: ...
async def close(self) -> None: ...
class LocalAdapter:
@@ -36,22 +58,59 @@ class LocalAdapter:
async def enumerate_entries(self) -> AsyncIterator[Entry]:
self.validate_config()
for item in self.root.rglob("*"):
for item in sorted(self.root.rglob("*"), key=lambda candidate: candidate.as_posix()):
relative = item.relative_to(self.root).as_posix()
if item.is_symlink():
yield Entry(relative, "symlink")
elif item.is_file():
yield Entry(relative, "file", item.stat().st_size)
elif item.is_dir():
yield Entry(relative, "directory")
metadata = item.lstat()
if stat.S_ISLNK(metadata.st_mode):
target = os.readlink(item)
target_path = Path(target)
if (
target_path.is_absolute()
or "\\" in target
or ".." in target_path.parts
or not target
):
raise SourceError(f"symlink target for {relative!r} is unsafe")
yield Entry(
relative,
"symlink",
0,
stat.S_IMODE(metadata.st_mode),
metadata.st_mtime_ns,
target,
)
elif stat.S_ISREG(metadata.st_mode):
yield Entry(
relative,
"file",
metadata.st_size,
stat.S_IMODE(metadata.st_mode),
metadata.st_mtime_ns,
)
elif stat.S_ISDIR(metadata.st_mode):
yield Entry(
relative,
"directory",
0,
stat.S_IMODE(metadata.st_mode),
metadata.st_mtime_ns,
)
else:
raise SourceError(f"local source entry {relative!r} has an unsupported type")
async def close(self) -> None:
return None
async def open_content(self, path: str) -> AsyncIterator[bytes]:
candidate = (self.root / path).resolve()
if (
not candidate.is_relative_to(self.root)
or not candidate.is_file()
or candidate.is_symlink()
):
requested = Path(path)
if not path or requested.is_absolute() or "\\" in path or ".." in requested.parts:
raise SourceError("invalid local source entry")
candidate = self.root / requested
metadata = candidate.lstat()
if not stat.S_ISREG(metadata.st_mode) or candidate.is_symlink():
raise SourceError("invalid local source entry")
resolved = candidate.resolve()
if not resolved.is_relative_to(self.root):
raise SourceError("invalid local source entry")
with candidate.open("rb") as handle:
while chunk := handle.read(1024 * 1024):
File diff suppressed because it is too large Load Diff
+868 -4
View File
@@ -4,16 +4,62 @@ from __future__ import annotations
import argparse
import asyncio
import base64
import binascii
import json
import os
import socket
from collections.abc import Callable, Sequence
from contextlib import suppress
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from alembic import command
from alembic.config import Config
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import async_sessionmaker
from backup_tool import __version__
from backup_tool.config import Settings
from .db.engine import assert_schema_current, create_engine
from .db.models import (
Backup,
Base,
Execution,
Job,
Repository,
RepositoryDataKeyEpoch,
Source,
)
from .repository import (
begin_key_rotation,
finish_key_rotation,
inspect_repository,
install_signing_key,
load_signing_key,
reconcile_key_rotations,
replace_active_data_key,
)
from .scheduler import run_scheduler
from .security.recovery_bundle import (
RecoveryBundleError,
decrypt_bundle,
encrypt_bundle,
read_bundle_file,
read_passphrase_fd,
write_bundle_exclusive,
)
from .security.repository_crypto import (
RepositoryKeyError,
create_data_key,
install_data_key,
load_data_key,
)
from .web import run_web
from .worker import run_worker
RoleHandler = Callable[[Settings], int]
@@ -25,8 +71,8 @@ def _placeholder_role(_settings: Settings) -> int:
ROLE_HANDLERS: dict[str, RoleHandler] = {
"web": _placeholder_role,
"scheduler": _placeholder_role,
"web": run_web,
"scheduler": run_scheduler,
"worker": run_worker,
"admin": _placeholder_role,
}
@@ -37,14 +83,34 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--version", action="version", version=__version__)
subparsers = parser.add_subparsers(dest="role", required=True)
for role in DATABASE_ROLES:
subparsers.add_parser(role, help=f"run the {role} role")
role_parser = subparsers.add_parser(role, help=f"run the {role} role")
if role == "admin":
admin_parsers = role_parser.add_subparsers(dest="admin_command")
repository_key = admin_parsers.add_parser("repository-key")
repository_key_parsers = repository_key.add_subparsers(dest="repository_key_command")
rotate = repository_key_parsers.add_parser("rotate")
rotate.add_argument("--repository-id", required=True)
recovery = admin_parsers.add_parser("recovery")
recovery_parsers = recovery.add_subparsers(dest="recovery_command", required=True)
export = recovery_parsers.add_parser("export")
export.add_argument("--output", required=True, type=Path)
export.add_argument("--passphrase-fd", required=True, type=int)
validate = recovery_parsers.add_parser("validate")
validate.add_argument("--input", required=True, type=Path)
validate.add_argument("--passphrase-fd", required=True, type=int)
import_ = recovery_parsers.add_parser("import")
import_.add_argument("--input", required=True, type=Path)
import_.add_argument("--passphrase-fd", required=True, type=int)
migrate = subparsers.add_parser("migrate", help="manage the metadata schema")
migrate.add_argument("action", choices=("upgrade", "downgrade", "current"))
health = subparsers.add_parser("health", help="check runtime role readiness")
health.add_argument("health_role", choices=("web", "scheduler", "worker"))
return parser
def build_alembic_config(settings: Settings) -> Config:
backend_root = Path(__file__).resolve().parents[2]
default_root = Path(__file__).resolve().parents[2]
backend_root = Path(os.environ.get("BACKUP_TOOL_ALEMBIC_ROOT", default_root))
migration = Config(backend_root / "alembic.ini")
migration.set_main_option("sqlalchemy.url", settings.database_url)
return migration
@@ -61,6 +127,24 @@ def require_current_schema(settings: Settings) -> None:
asyncio.run(check())
def run_role_healthcheck(settings: Settings, role: str) -> int:
"""Verify the selected isolated runtime role is ready to serve work."""
if role != "web":
from backup_tool.observability.health import check_role_readiness
asyncio.run(check_role_readiness(settings, role))
return 0
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.settimeout(2)
client.connect(str(settings.web_socket_path))
request = b"GET /readyz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
client.sendall(request)
status_line = client.recv(64).split(b"\r\n", 1)[0]
if status_line != b"HTTP/1.1 200 OK":
raise RuntimeError("web role is not ready")
return 0
def run_migration(action: str, settings: Settings) -> int:
migration = build_alembic_config(settings)
if action == "upgrade":
@@ -76,12 +160,792 @@ def load_settings() -> Settings:
return Settings() # type: ignore[call-arg]
async def rotate_repository_key(
settings: Settings,
repository_id: str,
*,
after_database_commit: Callable[[], None] | None = None,
) -> dict[str, str]:
"""Journal a key rotation so a crash always has a deterministic recovery path."""
engine = create_engine(settings)
sessions = async_sessionmaker(engine, expire_on_commit=False)
new_key_path: Path | None = None
database_committed = False
journal_started = False
root: Path | None = None
old_key_id = ""
new_key_id = ""
try:
async with sessions() as db:
await reconcile_key_rotations(settings, db)
await db.rollback()
async with db.begin():
repository = await db.scalar(
select(Repository).where(Repository.id == repository_id).with_for_update()
)
if repository is None:
raise ValueError("repository was not found")
if repository.encryption != "aes-256-gcm" or repository.active_data_key_id is None:
raise ValueError("repository encryption is not enabled")
inspected = inspect_repository(settings, Path(repository.root))
root = inspected.root
if (
inspected.encryption != "aes-256-gcm"
or inspected.data_key_id != repository.active_data_key_id
):
raise ValueError("repository encryption metadata is invalid")
epochs = list(
(
await db.scalars(
select(RepositoryDataKeyEpoch).where(
RepositoryDataKeyEpoch.repository_id == repository.id
)
)
).all()
)
active = [epoch for epoch in epochs if epoch.state == "active"]
if len(active) != 1 or active[0].key_id != repository.active_data_key_id:
raise ValueError("repository key epochs are invalid")
old_key_id = active[0].key_id
new_key_id, new_key_path = create_data_key(settings, inspected.repository_id)
begin_key_rotation(
inspected.root,
repository.id,
inspected.repository_id,
old_key_id,
new_key_id,
)
journal_started = True
active[0].state = "retired"
active[0].retired_at = datetime.now(UTC)
db.add(
RepositoryDataKeyEpoch(
repository_id=repository.id,
key_id=new_key_id,
state="active",
)
)
repository.active_data_key_id = new_key_id
database_committed = True
if after_database_commit is not None:
after_database_commit()
if root is None:
raise ValueError("repository rotation metadata is invalid")
replace_active_data_key(root, old_key_id, new_key_id)
finish_key_rotation(root)
journal_started = False
return {
"repository_id": repository_id,
"retired_key_id": old_key_id,
"active_key_id": new_key_id,
}
finally:
if not database_committed:
if journal_started and root is not None:
with suppress(ValueError):
finish_key_rotation(root)
if new_key_path is not None:
new_key_path.unlink(missing_ok=True)
await engine.dispose()
async def recovery_export_payload(settings: Settings) -> dict[str, Any]:
"""Collect the current repository trust catalog and offline key material."""
engine = create_engine(settings)
sessions = async_sessionmaker(engine, expire_on_commit=False)
try:
async with sessions() as db:
await reconcile_key_rotations(settings, db)
await db.rollback()
repositories = list(
(await db.scalars(select(Repository).order_by(Repository.id))).all()
)
catalog_repositories: list[dict[str, Any]] = []
key_records: list[dict[str, Any]] = []
for repository in repositories:
inspected = inspect_repository(settings, Path(repository.root))
if inspected.encryption != repository.encryption or (
repository.encryption == "aes-256-gcm"
and repository.active_data_key_id != inspected.data_key_id
):
raise RecoveryBundleError("recovery bundle export is unavailable")
private_key = load_signing_key(
settings,
inspected.repository_id,
repository.signing_key_id,
repository.signing_public_key,
)
epochs = list(
(
await db.scalars(
select(RepositoryDataKeyEpoch)
.where(RepositoryDataKeyEpoch.repository_id == repository.id)
.order_by(RepositoryDataKeyEpoch.key_id)
)
).all()
)
active = [epoch for epoch in epochs if epoch.state == "active"]
if repository.encryption == "aes-256-gcm":
if (
repository.active_data_key_id is None
or len(active) != 1
or active[0].key_id != repository.active_data_key_id
):
raise RecoveryBundleError("recovery bundle export is unavailable")
elif epochs:
raise RecoveryBundleError("recovery bundle export is unavailable")
data_keys: list[dict[str, str]] = []
for epoch in epochs:
data_keys.append(
{
"key_id": epoch.key_id,
"key": base64.b64encode(
load_data_key(settings, inspected.repository_id, epoch.key_id)
).decode("ascii"),
}
)
catalog_repositories.append(
{
"active_data_key_id": repository.active_data_key_id,
"compression": repository.compression,
"data_key_epochs": [
{
"key_id": epoch.key_id,
"retired_at": (
epoch.retired_at.isoformat()
if epoch.retired_at is not None
else None
),
"state": epoch.state,
}
for epoch in epochs
],
"database_id": repository.id,
"encryption": repository.encryption,
"format_version": repository.format_version,
"name": repository.name,
"repository_id": inspected.repository_id,
"root": repository.root,
"signing_key_id": repository.signing_key_id,
"signing_public_key": repository.signing_public_key,
"state": repository.state,
}
)
key_records.append(
{
"data_keys": data_keys,
"repository_id": inspected.repository_id,
"signing_private_key": base64.b64encode(
private_key.private_bytes(
serialization.Encoding.Raw,
serialization.PrivateFormat.Raw,
serialization.NoEncryption(),
)
).decode("ascii"),
}
)
sources = list((await db.scalars(select(Source).order_by(Source.id))).all())
jobs = list((await db.scalars(select(Job).order_by(Job.id))).all())
executions = list((await db.scalars(select(Execution).order_by(Execution.id))).all())
backups = list((await db.scalars(select(Backup).order_by(Backup.id))).all())
if any(source.secret_refs for source in sources):
raise RecoveryBundleError("recovery bundle export is unavailable")
return {
"catalog": {
"backups": [
{
"created_at": backup.created_at.isoformat(),
"data_key_id": backup.data_key_id,
"execution_id": backup.execution_id,
"id": backup.id,
"integrity": backup.integrity,
"logical_bytes": backup.logical_bytes,
"manifest_digest": backup.manifest_digest,
"manifest_id": backup.manifest_id,
"parent_backup_id": backup.parent_backup_id,
"pinned": backup.pinned,
"stored_bytes": backup.stored_bytes,
"tombstoned_at": (
backup.tombstoned_at.isoformat()
if backup.tombstoned_at is not None
else None
),
}
for backup in backups
],
"executions": [
{
"attempt": execution.attempt,
"id": execution.id,
"job_id": execution.job_id,
"progress": execution.progress,
"revision": execution.revision,
"trigger": execution.trigger,
}
for execution in executions
],
"format": "backup-tool-recovery-catalog",
"jobs": [
{
"allow_empty": job.allow_empty,
"exclusions": job.exclusions,
"id": job.id,
"name": job.name,
"repository_id": job.repository_id,
"requested_mode": job.requested_mode,
"retention": job.retention,
"source_id": job.source_id,
}
for job in jobs
],
"repositories": catalog_repositories,
"sources": [
{
"id": source.id,
"kind": source.kind,
"name": source.name,
"public_config": source.public_config,
}
for source in sources
],
"version": 2,
},
"keys": key_records,
}
except (OSError, RepositoryKeyError, ValueError) as error:
if isinstance(error, RecoveryBundleError):
raise
raise RecoveryBundleError("recovery bundle export is unavailable") from error
finally:
await engine.dispose()
def _validate_legacy_recovery_payload(payload: dict[str, Any]) -> int:
"""Validate the authenticated export shape without exposing key material."""
try:
if set(payload) != {"catalog", "keys"}:
raise ValueError
catalog = payload["catalog"]
keys = payload["keys"]
if (
not isinstance(catalog, dict)
or set(catalog) != {"format", "repositories", "version"}
or catalog["format"] != "backup-tool-recovery-catalog"
or catalog["version"] != 1
or not isinstance(catalog["repositories"], list)
or not isinstance(keys, list)
or len(catalog["repositories"]) != len(keys)
):
raise ValueError
seen: set[str] = set()
for repository, key_record in zip(catalog["repositories"], keys, strict=True):
if not isinstance(repository, dict) or not isinstance(key_record, dict):
raise ValueError
required_repository = {
"active_data_key_id",
"compression",
"data_key_epochs",
"database_id",
"encryption",
"format_version",
"name",
"repository_id",
"root",
"signing_key_id",
"signing_public_key",
"state",
}
if set(repository) != required_repository or set(key_record) != {
"data_keys",
"repository_id",
"signing_private_key",
}:
raise ValueError
repository_id = repository["repository_id"]
if (
not isinstance(repository_id, str)
or not repository_id
or repository_id in seen
or key_record["repository_id"] != repository_id
or not isinstance(repository["data_key_epochs"], list)
or not isinstance(key_record["data_keys"], list)
):
raise ValueError
seen.add(repository_id)
private_key = base64.b64decode(key_record["signing_private_key"], validate=True)
public_key = bytes.fromhex(repository["signing_public_key"])
if len(private_key) != 32 or len(public_key) != 32:
raise ValueError
signing_key = Ed25519PrivateKey.from_private_bytes(private_key)
derived_public = signing_key.public_key().public_bytes(
serialization.Encoding.Raw,
serialization.PublicFormat.Raw,
)
if derived_public != public_key:
raise ValueError
epochs = repository["data_key_epochs"]
data_keys = key_record["data_keys"]
if len(epochs) != len(data_keys):
raise ValueError
epoch_ids: set[str] = set()
for epoch, data_key in zip(epochs, data_keys, strict=True):
if (
not isinstance(epoch, dict)
or set(epoch) != {"key_id", "retired_at", "state"}
or not isinstance(data_key, dict)
or set(data_key) != {"key", "key_id"}
or not isinstance(epoch["key_id"], str)
or epoch["key_id"] in epoch_ids
or data_key["key_id"] != epoch["key_id"]
or len(base64.b64decode(data_key["key"], validate=True)) != 32
):
raise ValueError
epoch_ids.add(epoch["key_id"])
active = [epoch for epoch in epochs if epoch["state"] == "active"]
if repository["encryption"] == "aes-256-gcm":
if len(active) != 1 or active[0]["key_id"] != repository["active_data_key_id"]:
raise ValueError
elif (
repository["encryption"] != "none"
or epochs
or repository["active_data_key_id"] is not None
):
raise ValueError
return len(seen)
except (binascii.Error, KeyError, TypeError, ValueError) as error:
raise RecoveryBundleError("recovery bundle is invalid") from error
def _validated_recovery_catalog(
payload: dict[str, Any],
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Validate a v2 catalog before any filesystem or metadata mutation."""
try:
if set(payload) != {"catalog", "keys"}:
raise ValueError
catalog = payload["catalog"]
keys = payload["keys"]
required_catalog = {
"backups",
"executions",
"format",
"jobs",
"repositories",
"sources",
"version",
}
if (
not isinstance(catalog, dict)
or set(catalog) != required_catalog
or catalog["format"] != "backup-tool-recovery-catalog"
or catalog["version"] != 2
or not isinstance(keys, list)
or any(
not isinstance(catalog[name], list)
for name in required_catalog - {"format", "version"}
)
or len(catalog["repositories"]) != len(keys)
):
raise ValueError
repositories = catalog["repositories"]
key_by_repository: dict[str, dict[str, Any]] = {}
repository_by_id: dict[str, dict[str, Any]] = {}
canonical_ids: set[str] = set()
for repository, key_record in zip(repositories, keys, strict=True):
if not isinstance(repository, dict) or not isinstance(key_record, dict):
raise ValueError
required_repository = {
"active_data_key_id",
"compression",
"data_key_epochs",
"database_id",
"encryption",
"format_version",
"name",
"repository_id",
"root",
"signing_key_id",
"signing_public_key",
"state",
}
if set(repository) != required_repository or set(key_record) != {
"data_keys",
"repository_id",
"signing_private_key",
}:
raise ValueError
database_id = repository["database_id"]
canonical_id = repository["repository_id"]
if (
not isinstance(database_id, str)
or not isinstance(canonical_id, str)
or not database_id
or not canonical_id
or database_id in repository_by_id
or canonical_id in canonical_ids
or key_record["repository_id"] != canonical_id
or not isinstance(repository["data_key_epochs"], list)
or not isinstance(key_record["data_keys"], list)
):
raise ValueError
private_key = base64.b64decode(key_record["signing_private_key"], validate=True)
public_key = bytes.fromhex(repository["signing_public_key"])
signing_key = Ed25519PrivateKey.from_private_bytes(private_key)
if (
len(public_key) != 32
or signing_key.public_key().public_bytes(
serialization.Encoding.Raw, serialization.PublicFormat.Raw
)
!= public_key
):
raise ValueError
epochs = repository["data_key_epochs"]
data_keys = key_record["data_keys"]
if len(epochs) != len(data_keys):
raise ValueError
epoch_ids: set[str] = set()
for epoch, data_key in zip(epochs, data_keys, strict=True):
if (
not isinstance(epoch, dict)
or set(epoch) != {"key_id", "retired_at", "state"}
or epoch.get("state") not in {"active", "retired"}
or not isinstance(epoch.get("key_id"), str)
or epoch["key_id"] in epoch_ids
or not isinstance(data_key, dict)
or set(data_key) != {"key", "key_id"}
or data_key["key_id"] != epoch["key_id"]
or len(base64.b64decode(data_key["key"], validate=True)) != 32
):
raise ValueError
epoch_ids.add(epoch["key_id"])
active = [epoch for epoch in epochs if epoch["state"] == "active"]
if repository["encryption"] == "aes-256-gcm":
if len(active) != 1 or active[0]["key_id"] != repository["active_data_key_id"]:
raise ValueError
elif (
repository["encryption"] != "none"
or epochs
or repository["active_data_key_id"] is not None
):
raise ValueError
repository_by_id[database_id] = repository
canonical_ids.add(canonical_id)
key_by_repository[canonical_id] = key_record
source_ids = _catalog_ids(catalog["sources"], {"id", "kind", "name", "public_config"})
job_ids = _catalog_ids(
catalog["jobs"],
{
"allow_empty",
"exclusions",
"id",
"name",
"repository_id",
"requested_mode",
"retention",
"source_id",
},
)
for job in catalog["jobs"]:
if job["source_id"] not in source_ids or job["repository_id"] not in repository_by_id:
raise ValueError
execution_ids = _catalog_ids(
catalog["executions"], {"attempt", "id", "job_id", "progress", "revision", "trigger"}
)
for execution in catalog["executions"]:
if execution["job_id"] not in job_ids:
raise ValueError
backup_ids = _catalog_ids(
catalog["backups"],
{
"created_at",
"data_key_id",
"execution_id",
"id",
"integrity",
"logical_bytes",
"manifest_digest",
"manifest_id",
"parent_backup_id",
"pinned",
"stored_bytes",
"tombstoned_at",
},
)
for backup in catalog["backups"]:
if backup["execution_id"] not in execution_ids:
raise ValueError
_catalog_datetime(backup["created_at"])
_catalog_datetime(backup["tombstoned_at"], allow_none=True)
if (
backup["parent_backup_id"] is not None
and backup["parent_backup_id"] not in backup_ids
):
raise ValueError
return (
catalog,
[key_by_repository[repository["repository_id"]] for repository in repositories],
)
except (binascii.Error, KeyError, TypeError, ValueError) as error:
raise RecoveryBundleError("recovery bundle is invalid") from error
def _catalog_ids(rows: list[Any], required: set[str]) -> set[str]:
identifiers: set[str] = set()
for row in rows:
if not isinstance(row, dict) or set(row) != required or not isinstance(row.get("id"), str):
raise ValueError
if not row["id"] or row["id"] in identifiers:
raise ValueError
identifiers.add(row["id"])
return identifiers
def _catalog_datetime(value: object, *, allow_none: bool = False) -> datetime | None:
if value is None and allow_none:
return None
if not isinstance(value, str):
raise ValueError
parsed = datetime.fromisoformat(value.replace("Z", "+00"))
if parsed.tzinfo is None:
raise ValueError
return parsed.astimezone(UTC)
def _validate_recovery_payload(payload: dict[str, Any]) -> int:
catalog = payload.get("catalog")
if isinstance(catalog, dict) and catalog.get("version") == 1:
return _validate_legacy_recovery_payload(payload)
validated_catalog, _keys = _validated_recovery_catalog(payload)
return len(validated_catalog["repositories"])
def _install_or_verify_recovery_keys(
settings: Settings,
repository: dict[str, Any],
key_record: dict[str, Any],
installed_paths: list[Path],
) -> None:
inspected = inspect_repository(settings, Path(repository["root"]))
if (
inspected.repository_id != repository["repository_id"]
or inspected.encryption != repository["encryption"]
or inspected.data_key_id != repository["active_data_key_id"]
):
raise RecoveryBundleError("recovery bundle is invalid")
private_key = base64.b64decode(key_record["signing_private_key"], validate=True)
signing_path = settings.data_dir / "repository-keys" / f"{inspected.repository_id}.ed25519"
if signing_path.exists() or signing_path.is_symlink():
loaded = load_signing_key(
settings,
inspected.repository_id,
repository["signing_key_id"],
repository["signing_public_key"],
)
if (
loaded.private_bytes(
serialization.Encoding.Raw,
serialization.PrivateFormat.Raw,
serialization.NoEncryption(),
)
!= private_key
):
raise RecoveryBundleError("recovery destination has conflicting keys")
else:
installed_paths.append(
install_signing_key(
settings,
inspected.repository_id,
repository["signing_key_id"],
repository["signing_public_key"],
private_key,
)
)
for data_key in key_record["data_keys"]:
key_value = base64.b64decode(data_key["key"], validate=True)
key_path = (
settings.data_dir
/ "repository-data-keys"
/ f"{inspected.repository_id}.{data_key['key_id']}.key"
)
if key_path.exists() or key_path.is_symlink():
if load_data_key(settings, inspected.repository_id, data_key["key_id"]) != key_value:
raise RecoveryBundleError("recovery destination has conflicting keys")
else:
installed_paths.append(
install_data_key(settings, inspected.repository_id, data_key["key_id"], key_value)
)
async def import_recovery_payload(
settings: Settings,
payload: dict[str, Any],
*,
after_key_install: Callable[[], None] | None = None,
) -> int:
"""Install a complete authenticated catalog into an empty, migrated database."""
catalog, key_records = _validated_recovery_catalog(payload)
engine = create_engine(settings)
sessions = async_sessionmaker(engine, expire_on_commit=False)
installed_paths: list[Path] = []
completed = False
try:
async with sessions() as db:
for table in Base.metadata.sorted_tables:
if await db.scalar(select(func.count()).select_from(table)):
raise RecoveryBundleError("recovery destination is not empty")
await db.rollback()
for repository, key_record in zip(catalog["repositories"], key_records, strict=True):
_install_or_verify_recovery_keys(settings, repository, key_record, installed_paths)
if after_key_install is not None:
after_key_install()
async with db.begin():
for repository in catalog["repositories"]:
db.add(
Repository(
id=repository["database_id"],
name=repository["name"],
root=repository["root"],
format_version=repository["format_version"],
compression=repository["compression"],
encryption=repository["encryption"],
signing_key_id=repository["signing_key_id"],
signing_public_key=repository["signing_public_key"],
active_data_key_id=repository["active_data_key_id"],
state=repository["state"],
)
)
for repository in catalog["repositories"]:
for epoch in repository["data_key_epochs"]:
db.add(
RepositoryDataKeyEpoch(
repository_id=repository["database_id"],
key_id=epoch["key_id"],
state=epoch["state"],
)
)
await db.flush()
for source in catalog["sources"]:
db.add(
Source(
id=source["id"],
name=source["name"],
kind=source["kind"],
public_config=source["public_config"],
secret_refs=[],
state="unavailable",
)
)
await db.flush()
for job in catalog["jobs"]:
db.add(
Job(
id=job["id"],
name=job["name"],
source_id=job["source_id"],
repository_id=job["repository_id"],
requested_mode=job["requested_mode"],
exclusions=job["exclusions"],
retention=job["retention"],
enabled=False,
allow_empty=job["allow_empty"],
state="archived",
)
)
await db.flush()
for execution in catalog["executions"]:
db.add(
Execution(
id=execution["id"],
job_id=execution["job_id"],
schedule_id=None,
trigger=execution["trigger"],
state="committed",
attempt=execution["attempt"],
progress=execution["progress"],
revision=execution["revision"],
)
)
await db.flush()
for backup in catalog["backups"]:
db.add(
Backup(
id=backup["id"],
execution_id=backup["execution_id"],
parent_backup_id=None,
manifest_id=backup["manifest_id"],
manifest_digest=backup["manifest_digest"],
logical_bytes=backup["logical_bytes"],
stored_bytes=backup["stored_bytes"],
integrity=backup["integrity"],
data_key_id=backup["data_key_id"],
pinned=backup["pinned"],
created_at=_catalog_datetime(backup["created_at"]),
tombstoned_at=_catalog_datetime(
backup["tombstoned_at"], allow_none=True
),
)
)
await db.flush()
for backup in catalog["backups"]:
if backup["parent_backup_id"] is not None:
imported = await db.get(Backup, backup["id"])
if imported is None:
raise RecoveryBundleError("recovery bundle is invalid")
imported.parent_backup_id = backup["parent_backup_id"]
completed = True
return len(catalog["repositories"])
except (OSError, RepositoryKeyError, ValueError) as error:
if isinstance(error, RecoveryBundleError):
raise
raise RecoveryBundleError("recovery import failed") from error
finally:
await engine.dispose()
if not completed:
for path in installed_paths:
path.unlink(missing_ok=True)
def run_admin(settings: Settings, args: argparse.Namespace) -> int:
if (
getattr(args, "admin_command", None) == "repository-key"
and getattr(args, "repository_key_command", None) == "rotate"
):
result = asyncio.run(rotate_repository_key(settings, args.repository_id))
print(json.dumps(result, sort_keys=True))
return 0
if getattr(args, "admin_command", None) == "recovery":
passphrase = read_passphrase_fd(args.passphrase_fd)
if args.recovery_command == "export":
payload = asyncio.run(recovery_export_payload(settings))
write_bundle_exclusive(args.output, encrypt_bundle(payload, passphrase))
print(json.dumps({"repositories": len(payload["keys"]), "status": "exported"}))
return 0
if args.recovery_command == "validate":
payload = decrypt_bundle(read_bundle_file(args.input), passphrase)
validation_result: dict[str, object] = {
"repositories": _validate_recovery_payload(payload),
"status": "valid",
}
print(json.dumps(validation_result))
return 0
if args.recovery_command == "import":
payload = decrypt_bundle(read_bundle_file(args.input), passphrase)
imported = asyncio.run(import_recovery_payload(settings, payload))
print(json.dumps({"repositories": imported, "status": "imported"}))
return 0
raise ValueError("admin command is invalid")
def main(argv: Sequence[str] | None = None, *, settings: Settings | None = None) -> int:
args = build_parser().parse_args(argv)
runtime_settings = settings or load_settings()
if args.role == "migrate":
return run_migration(args.action, runtime_settings)
if args.role == "health":
return run_role_healthcheck(runtime_settings, args.health_role)
require_current_schema(runtime_settings)
if args.role == "admin":
return run_admin(runtime_settings, args)
return ROLE_HANDLERS[args.role](runtime_settings)
+17
View File
@@ -19,6 +19,7 @@ class Settings(BaseSettings):
)
data_dir: Path = Path("/var/lib/backup-tool")
web_socket_path: Path = Path("/tmp/backup-tool-web.sock")
database_url: str = "sqlite+aiosqlite:////var/lib/backup-tool/metadata.db"
repository_roots: tuple[Path, ...]
local_source_roots: tuple[Path, ...]
@@ -29,6 +30,21 @@ class Settings(BaseSettings):
session_ttl_seconds: int = Field(default=28_800, ge=60, le=2_592_000)
cors_origins: tuple[str, ...] = ()
worker_concurrency: Literal[1] = 1
notification_delivery_lease_seconds: int = Field(default=60, ge=5, le=3600)
notification_connect_timeout_seconds: float = Field(default=5.0, gt=0, le=60)
notification_read_timeout_seconds: float = Field(default=10.0, gt=0, le=120)
notification_max_attempts: int = Field(default=5, ge=1, le=20)
notification_retry_cap_seconds: int = Field(default=3600, ge=1, le=86_400)
notification_global_rate_per_minute: int = Field(default=120, ge=1, le=10_000)
notification_default_rate_per_minute: int = Field(default=60, ge=1, le=10_000)
notification_max_webhook_body_bytes: int = Field(default=65_536, ge=256, le=1_048_576)
notification_max_response_bytes: int = Field(default=16_384, ge=256, le=1_048_576)
ssh_connect_timeout_seconds: float = Field(default=5.0, gt=0, le=60)
ssh_operation_timeout_seconds: float = Field(default=10.0, gt=0, le=120)
ssh_read_chunk_bytes: int = Field(default=1_048_576, ge=4096, le=8_388_608)
ssh_list_read_aheads: int = Field(default=32, ge=1, le=256)
ssh_max_entries: int = Field(default=1_000_000, ge=1, le=10_000_000)
ssh_max_traversal_depth: int = Field(default=128, ge=1, le=1024)
min_free_bytes: int = Field(default=1_073_741_824, ge=0)
min_free_percent: int = Field(default=5, gt=0, lt=100)
sqlite_busy_timeout_ms: int = Field(default=5_000, ge=1_000, le=120_000)
@@ -37,6 +53,7 @@ class Settings(BaseSettings):
@field_validator(
"data_dir",
"master_key_file",
"web_socket_path",
mode="before",
)
@classmethod
+147 -11
View File
@@ -95,6 +95,9 @@ class Repository(IdentityMixin, TimestampMixin, Base):
format_version: Mapped[int] = mapped_column(Integer, nullable=False)
compression: Mapped[str] = mapped_column(String(32), nullable=False)
encryption: Mapped[str] = mapped_column(String(32), nullable=False)
signing_key_id: Mapped[str] = mapped_column(String(64), nullable=False, server_default="")
signing_public_key: Mapped[str] = mapped_column(String(64), nullable=False, server_default="")
active_data_key_id: Mapped[str | None] = mapped_column(String(36))
state: Mapped[str] = mapped_column(String(32), nullable=False, default="active")
__table_args__ = (
CheckConstraint("format_version > 0", name="format_version_positive"),
@@ -102,6 +105,25 @@ class Repository(IdentityMixin, TimestampMixin, Base):
)
class RepositoryDataKeyEpoch(IdentityMixin, TimestampMixin, Base):
__tablename__ = "repository_data_key_epochs"
repository_id: Mapped[str] = mapped_column(ForeignKey("repositories.id", ondelete="RESTRICT"))
key_id: Mapped[str] = mapped_column(String(36), nullable=False)
state: Mapped[str] = mapped_column(String(16), nullable=False)
retired_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
__table_args__ = (
UniqueConstraint("repository_id", "key_id", name="repository_key_epoch"),
CheckConstraint("state IN ('active','retired')", name="repository_data_key_epoch_state"),
Index("ix_repository_data_key_epochs_repository_id", "repository_id"),
Index(
"uq_repository_data_key_epochs_active",
"repository_id",
unique=True,
sqlite_where=text("state = 'active'"),
),
)
class Source(IdentityMixin, TimestampMixin, Base):
__tablename__ = "sources"
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
@@ -111,7 +133,7 @@ class Source(IdentityMixin, TimestampMixin, Base):
state: Mapped[str] = mapped_column(String(32), nullable=False, default="active")
last_probe: Mapped[dict[str, Any] | None] = mapped_column(JSON)
__table_args__ = (
CheckConstraint("kind IN ('local','sftp','postgresql','mysql')", name="kind"),
CheckConstraint("kind IN ('local','ssh')", name="kind"),
CheckConstraint("state IN ('active','archived','unavailable')", name="state"),
)
@@ -216,6 +238,7 @@ class Backup(IdentityMixin, Base):
logical_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
stored_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
integrity: Mapped[str] = mapped_column(String(32), nullable=False, default="unverified")
data_key_id: Mapped[str | None] = mapped_column(String(36))
pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
tombstoned_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
created_at: Mapped[datetime] = mapped_column(
@@ -236,6 +259,9 @@ class Restore(IdentityMixin, TimestampMixin, Base):
backup_id: Mapped[str] = mapped_column(ForeignKey("backups.id", ondelete="RESTRICT"))
destination: Mapped[str] = mapped_column(Text, nullable=False)
selection: Mapped[list[str]] = mapped_column(JSON, nullable=False)
dry_run: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0"
)
overwrite_policy: Mapped[str] = mapped_column(String(32), nullable=False)
state: Mapped[str] = mapped_column(String(32), nullable=False, default="queued")
result: Mapped[dict[str, Any] | None] = mapped_column(JSON)
@@ -268,33 +294,143 @@ class AuditEvent(IdentityMixin, Base):
class NotificationSubscription(IdentityMixin, TimestampMixin, Base):
"""A typed, mutable outbound channel configuration; credential material is never here."""
__tablename__ = "notification_subscriptions"
channel: Mapped[str] = mapped_column(String(32), nullable=False)
event_filters: Mapped[list[str]] = mapped_column(JSON, nullable=False)
destination_config: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
# Retained only to make the migration from the unused v1-shaped table lossless.
# New webhook secrets live in NotificationSigningKey and SMTP secrets in settings.
secret_id: Mapped[str | None] = mapped_column(ForeignKey("secrets.id", ondelete="RESTRICT"))
state: Mapped[str] = mapped_column(String(32), nullable=False, default="active")
rate_limit_per_minute: Mapped[int] = mapped_column(Integer, nullable=False, default=60)
rate_tokens: Mapped[float] = mapped_column(nullable=False, default=60.0)
rate_updated_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
__table_args__ = (
CheckConstraint("channel IN ('webhook','email')", name="channel"),
CheckConstraint("state IN ('active','disabled','archived')", name="state"),
CheckConstraint("rate_limit_per_minute > 0", name="rate_positive"),
CheckConstraint("rate_tokens >= 0", name="rate_tokens_nonnegative"),
CheckConstraint("revision > 0", name="revision_positive"),
)
class NotificationEvent(IdentityMixin, Base):
"""Immutable canonical outbox envelope, created in the source mutation transaction."""
__tablename__ = "notification_events"
type: Mapped[str] = mapped_column(String(96), nullable=False)
schema_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
occurred_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
correlation_id: Mapped[str] = mapped_column(String(36), nullable=False)
severity: Mapped[str] = mapped_column(String(16), nullable=False)
resource_refs: Mapped[dict[str, str]] = mapped_column(JSON, nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
canonical_envelope: Mapped[str] = mapped_column(Text, nullable=False)
deduplication_key: Mapped[str | None] = mapped_column(String(255), unique=True)
__table_args__ = (
CheckConstraint("schema_version = 1", name="schema_version"),
CheckConstraint("severity IN ('info','warning','error','security')", name="severity"),
Index("ix_notification_events_type_occurred", "type", "occurred_at"),
)
class NotificationDelivery(IdentityMixin, TimestampMixin, Base):
"""One durable delivery per matching subscription, with a lease-owned lifecycle."""
__tablename__ = "notification_deliveries"
event_id: Mapped[str] = mapped_column(String(36), nullable=False)
subscription_id: Mapped[str] = mapped_column(
ForeignKey("notification_subscriptions.id", ondelete="RESTRICT")
event_id: Mapped[str] = mapped_column(
ForeignKey("notification_events.id", ondelete="RESTRICT"), nullable=False
)
attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
state: Mapped[str] = mapped_column(String(32), nullable=False)
subscription_id: Mapped[str] = mapped_column(
ForeignKey("notification_subscriptions.id", ondelete="RESTRICT"), nullable=False
)
state: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
due_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
lease_owner: Mapped[str | None] = mapped_column(String(255))
lease_expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
terminal_reason: Mapped[str | None] = mapped_column(String(96))
response_class: Mapped[str | None] = mapped_column(String(64))
next_attempt_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
response_summary: Mapped[str | None] = mapped_column(String(512))
__table_args__ = (
CheckConstraint("attempt > 0", name="attempt_positive"),
CheckConstraint("state IN ('pending','delivered','retry','failed')", name="state"),
UniqueConstraint("event_id", "subscription_id", "attempt", name="uq_delivery_attempt"),
Index("ix_notification_deliveries_state_next", "state", "next_attempt_at"),
CheckConstraint("attempt_count >= 0", name="attempt_count_nonnegative"),
CheckConstraint("state IN ('pending','leased','delivered','retry','failed')", name="state"),
UniqueConstraint("event_id", "subscription_id", name="event_subscription"),
Index("ix_notification_deliveries_due", "state", "due_at"),
Index("ix_notification_deliveries_lease", "state", "lease_expires_at"),
)
class NotificationDeliveryAttempt(IdentityMixin, Base):
"""Append-only history. Diagnostics are bounded/redacted before persistence."""
__tablename__ = "notification_delivery_attempts"
delivery_id: Mapped[str] = mapped_column(
ForeignKey("notification_deliveries.id", ondelete="RESTRICT"), nullable=False
)
number: Mapped[int] = mapped_column(Integer, nullable=False)
started_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
outcome: Mapped[str] = mapped_column(String(32), nullable=False, default="started")
response_class: Mapped[str | None] = mapped_column(String(64))
diagnostic: Mapped[str | None] = mapped_column(String(512))
__table_args__ = (
CheckConstraint("number > 0", name="number_positive"),
CheckConstraint("outcome IN ('started','delivered','retry','failed')", name="outcome"),
UniqueConstraint("delivery_id", "number", name="delivery_number"),
Index("ix_notification_attempts_delivery", "delivery_id", "number"),
)
class NotificationSigningKey(IdentityMixin, TimestampMixin, Base):
__tablename__ = "notification_signing_keys"
subscription_id: Mapped[str] = mapped_column(
ForeignKey("notification_subscriptions.id", ondelete="RESTRICT"), nullable=False
)
version: Mapped[int] = mapped_column(Integer, nullable=False)
secret_id: Mapped[str] = mapped_column(ForeignKey("secrets.id", ondelete="RESTRICT"))
state: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
overlap_expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
__table_args__ = (
CheckConstraint("version > 0", name="version_positive"),
CheckConstraint("state IN ('active','overlap','retired')", name="state"),
UniqueConstraint("subscription_id", "version", name="subscription_version"),
Index("ix_notification_signing_keys_subscription", "subscription_id", "state"),
Index(
"uq_notification_signing_keys_active",
"subscription_id",
unique=True,
sqlite_where=text("state = 'active'"),
),
Index(
"uq_notification_signing_keys_overlap",
"subscription_id",
unique=True,
sqlite_where=text("state = 'overlap'"),
),
)
class NotificationEmailSettings(Base):
__tablename__ = "notification_email_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
host: Mapped[str] = mapped_column(String(255), nullable=False)
port: Mapped[int] = mapped_column(Integer, nullable=False, default=587)
username: Mapped[str] = mapped_column(String(255), nullable=False)
password_secret_id: Mapped[str] = mapped_column(
ForeignKey("secrets.id", ondelete="RESTRICT"), nullable=False
)
sender: Mapped[str] = mapped_column(String(320), nullable=False)
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
rate_limit_per_minute: Mapped[int] = mapped_column(Integer, nullable=False, default=60)
__table_args__ = (
CheckConstraint("id = 1", name="singleton"),
CheckConstraint("port > 0 AND port < 65536", name="port"),
CheckConstraint("max_attempts > 0", name="max_attempts"),
CheckConstraint("rate_limit_per_minute > 0", name="rate_positive"),
)
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
from pathlib import PurePosixPath
class ExclusionError(ValueError):
pass
def _validate(value: str) -> None:
path = PurePosixPath(value)
if not value or "\\" in value or path.is_absolute() or ".." in path.parts:
raise ExclusionError("exclusion paths must be normalized relative POSIX paths")
def matches(path: str, patterns: list[str]) -> bool:
"""Return whether normalized relative `path` is excluded by ordered gitignore-like rules."""
_validate(path)
excluded = False
candidate = PurePosixPath(path)
for pattern in patterns:
negated = pattern.startswith("!")
raw = pattern[1:] if negated else pattern
if not raw or raw.startswith("/") or "\\" in raw or ".." in PurePosixPath(raw).parts:
raise ExclusionError("exclusion pattern is invalid")
directory = raw.endswith("/")
raw = raw.rstrip("/")
if not raw:
raise ExclusionError("exclusion pattern is invalid")
matched = candidate.match(raw) or candidate.match(f"**/{raw}")
if directory:
matched = matched or any(
parent.match(raw) or parent.match(f"**/{raw}") for parent in candidate.parents
)
if matched:
excluded = not negated
return excluded
+42 -2
View File
@@ -8,6 +8,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from backup_tool.db.models import Execution, ExecutionEvent, Job
from backup_tool.notifications.events import emit_event
from backup_tool.security.redaction import redact
ACTIVE_STATES = frozenset({"queued", "preparing", "running", "verifying", "cancelling"})
@@ -44,7 +45,14 @@ def transition(current: str, target: str) -> str:
return target
async def enqueue(db: AsyncSession, job_id: str, trigger: str = "manual") -> Execution:
async def enqueue(
db: AsyncSession,
job_id: str,
trigger: str = "manual",
*,
schedule_id: str | None = None,
nominal_run_at: datetime | None = None,
) -> Execution:
"""Create exactly one active execution for an enabled active job."""
job = await db.get(Job, job_id)
if job is None:
@@ -52,7 +60,13 @@ async def enqueue(db: AsyncSession, job_id: str, trigger: str = "manual") -> Exe
if job.state != "active" or not job.enabled:
raise EnqueueError("job_disabled", "Job is disabled or archived.")
job_identifier = job.id
execution = Execution(job_id=job_identifier, trigger=trigger, progress={})
execution = Execution(
job_id=job_identifier,
trigger=trigger,
schedule_id=schedule_id,
nominal_run_at=nominal_run_at,
progress={},
)
db.add(execution)
try:
await db.flush()
@@ -221,6 +235,32 @@ async def record_event(db: AsyncSession, execution: Execution) -> ExecutionEvent
)
db.add(event)
await db.flush()
notification_type = {
"preparing": "execution.started",
"committed": "execution.committed",
"failed": "execution.failed",
"cancelled": "execution.cancelled",
}.get(execution.state)
if execution.state == "queued":
if execution.reason_code == "worker_lost":
notification_type = "execution.worker_recovered"
elif execution.attempt > 1:
notification_type = "execution.retry_queued"
else:
notification_type = "execution.queued"
if notification_type is not None:
await emit_event(
db,
notification_type,
correlation_id=execution.id,
resource={"execution_id": execution.id, "job_id": execution.job_id},
payload={
"attempt": execution.attempt,
"state": execution.state,
"reason_code": execution.reason_code,
},
deduplication_key=f"execution:{execution.id}:revision:{execution.revision}",
)
return event
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Protocol
class FaultInjector(Protocol):
def hit(self, point: str) -> None: ...
class NoFault:
def hit(self, point: str) -> None:
del point
class InjectedCrash(BaseException):
"""Test-only abrupt worker termination at a named durable fault point."""
class CrashAt:
def __init__(self, point: str) -> None:
self.point = point
def hit(self, point: str) -> None:
if point == self.point:
raise InjectedCrash(point)
+184
View File
@@ -0,0 +1,184 @@
from __future__ import annotations
import json
import shutil
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import cast
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backup_tool.db.models import Backup, Execution, Job, Repository
from backup_tool.notifications.events import emit_event
from backup_tool.retention import BackupLike, RetentionPolicy, retained_ids
from backup_tool.security.repository_crypto import RepositoryKeyError, decrypt_object, object_aad
@dataclass(frozen=True)
class GcReport:
tombstoned: int
purged_manifests: int
purged_blobs: int
quarantined: int
async def tombstone_expired(db: AsyncSession, now: datetime | None = None) -> int:
reference = now or datetime.now(UTC)
backups = list((await db.scalars(select(Backup))).all())
executions = {item.id: item for item in (await db.scalars(select(Execution))).all()}
jobs = {item.id: item for item in (await db.scalars(select(Job))).all()}
grouped: dict[str, list[Backup]] = {}
for backup in backups:
execution = executions.get(backup.execution_id)
if execution is None or execution.job_id not in jobs:
continue
grouped.setdefault(execution.job_id, []).append(backup)
tombstoned = 0
for job_id, items in grouped.items():
if not jobs[job_id].retention:
continue
policy = RetentionPolicy.from_dict(jobs[job_id].retention)
keep = retained_ids(cast(list[BackupLike], items), policy, reference)
for backup in items:
if backup.id not in keep and backup.tombstoned_at is None:
backup.tombstoned_at = reference
await emit_event(
db,
"retention.tombstoned",
correlation_id=backup.id,
resource={"backup_id": backup.id, "job_id": job_id},
payload={"outcome": "tombstoned"},
deduplication_key=f"backup:{backup.id}:tombstoned",
)
tombstoned += 1
await db.commit()
return tombstoned
async def process_retention_gc(db: AsyncSession, now: datetime | None = None) -> GcReport:
"""Run durable retention tombstoning and repository GC from the worker role."""
reference = now or datetime.now(UTC)
tombstoned = await tombstone_expired(db, reference)
reports: list[GcReport] = []
repositories = list((await db.scalars(select(Repository))).all())
for repository in repositories:
manifest_ids = set(
(
await db.scalars(
select(Backup.manifest_id)
.join(Execution, Backup.execution_id == Execution.id)
.join(Job, Execution.job_id == Job.id)
.where(
Job.repository_id == repository.id,
Backup.tombstoned_at.is_not(None),
)
)
).all()
)
if manifest_ids:
reports.append(purge_repository(Path(repository.root), manifest_ids, now=reference))
return GcReport(
tombstoned=tombstoned,
purged_manifests=sum(report.purged_manifests for report in reports),
purged_blobs=sum(report.purged_blobs for report in reports),
quarantined=sum(report.quarantined for report in reports),
)
def _manifest_digests(
path: Path,
*,
repository_id: str | None = None,
manifest_keys: Mapping[str, tuple[str, bytes]] | None = None,
) -> set[str] | None:
try:
if path.is_symlink() or not path.is_file():
return None
raw = path.read_bytes()
if raw.startswith(b"BTENC\x01"):
key_record = manifest_keys.get(path.stem) if manifest_keys is not None else None
if key_record is None or repository_id is None:
return None
key_id, key = key_record
raw = decrypt_object(key, object_aad(repository_id, key_id, "manifest", path.stem), raw)
payload = json.loads(raw.decode("utf-8"))
except (OSError, RepositoryKeyError, UnicodeDecodeError, json.JSONDecodeError):
return None
if not isinstance(payload, dict) or not isinstance(entries := payload.get("entries"), list):
return None
digests: set[str] = set()
for entry in entries:
if not isinstance(entry, dict):
return None
digest = entry.get("blob_digest")
if digest is None:
continue
if (
not isinstance(digest, str)
or len(digest) != 64
or any(character not in "0123456789abcdef" for character in digest)
):
return None
digests.add(digest)
return digests
def purge_repository(
root: Path,
tombstoned_manifest_ids: set[str],
*,
repository_id: str | None = None,
manifest_keys: Mapping[str, tuple[str, bytes]] | None = None,
grace: timedelta = timedelta(days=7),
now: datetime | None = None,
) -> GcReport:
reference = now or datetime.now(UTC)
manifests = root / "manifests"
blobs = root / "blobs" / "sha256"
quarantine = root / "quarantine"
quarantine.mkdir(exist_ok=True)
manifest_digests: dict[Path, set[str]] = {}
for manifest in manifests.glob("*.json"):
digests = _manifest_digests(
manifest,
repository_id=repository_id,
manifest_keys=manifest_keys,
)
if digests is None:
return GcReport(0, 0, 0, 0)
manifest_digests[manifest] = digests
purged_manifests = 0
for manifest_id in tombstoned_manifest_ids:
candidate = manifests / f"{manifest_id}.json"
if not candidate.is_file() or candidate.is_symlink():
continue
age = reference - datetime.fromtimestamp(candidate.stat().st_mtime, UTC)
if age >= grace:
candidate.unlink()
purged_manifests += 1
referenced: set[str] = set()
for manifest, digests in manifest_digests.items():
if manifest.exists():
referenced.update(digests)
purged_blobs = 0
quarantined = 0
if blobs.exists():
for blob in blobs.iterdir():
if not blob.is_file():
continue
if len(blob.name) != 64 or any(char not in "0123456789abcdef" for char in blob.name):
try:
shutil.move(str(blob), quarantine / blob.name)
except OSError:
continue
quarantined += 1
elif blob.name not in referenced and (
reference - datetime.fromtimestamp(blob.stat().st_mtime, UTC) >= grace
):
blob.unlink()
purged_blobs += 1
return GcReport(0, purged_manifests, purged_blobs, quarantined)
@@ -0,0 +1,5 @@
"""Durable, worker-dispatched operational notifications."""
from .events import EVENT_CATALOG, emit_event, validate_filters
__all__ = ["EVENT_CATALOG", "emit_event", "validate_filters"]
@@ -0,0 +1,335 @@
"""Worker-owned leased outbox dispatcher; sends happen only after a committed lease."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from backup_tool.config import Settings
from backup_tool.db.models import (
NotificationDelivery,
NotificationDeliveryAttempt,
NotificationEmailSettings,
NotificationEvent,
NotificationSigningKey,
NotificationSubscription,
Secret,
)
from backup_tool.notifications.email import EmailTransportError, deliver_email
from backup_tool.notifications.retry import (
RetryDecision,
retry_delay,
transport_decision,
webhook_decision,
)
from backup_tool.notifications.webhook import (
SigningMaterial,
WebhookTransportError,
deliver_webhook,
)
from backup_tool.security.secrets import EnvelopeCipher
from backup_tool.security.ssrf import Resolver, system_resolver
async def recover_notification_leases(db: AsyncSession) -> int:
"""An interrupted post-send lease becomes eligible again (at-least-once by design)."""
now = datetime.now(UTC)
deliveries = list(
(
await db.scalars(
select(NotificationDelivery).where(
NotificationDelivery.state == "leased",
NotificationDelivery.lease_expires_at < now,
)
)
).all()
)
for delivery in deliveries:
attempt = await db.scalar(
select(NotificationDeliveryAttempt).where(
NotificationDeliveryAttempt.delivery_id == delivery.id,
NotificationDeliveryAttempt.number == delivery.attempt_count,
NotificationDeliveryAttempt.outcome == "started",
)
)
if attempt is not None:
attempt.completed_at = now
attempt.outcome = "retry"
attempt.response_class = "lease_expired"
attempt.diagnostic = "abandoned_lease"
delivery.state = "retry"
delivery.lease_owner = None
delivery.lease_expires_at = None
delivery.due_at = now
if deliveries:
await db.commit()
return len(deliveries)
async def _claim_due(
db: AsyncSession, owner: str, lease_seconds: int
) -> tuple[NotificationDelivery, NotificationSubscription, NotificationEvent] | None:
now = datetime.now(UTC)
delivery_id = await db.scalar(
select(NotificationDelivery.id)
.where(
NotificationDelivery.state.in_(("pending", "retry")),
NotificationDelivery.due_at <= now,
)
.order_by(NotificationDelivery.due_at, NotificationDelivery.created_at)
.limit(1)
)
if delivery_id is None:
return None
result = await db.execute(
update(NotificationDelivery)
.where(
NotificationDelivery.id == delivery_id,
NotificationDelivery.state.in_(("pending", "retry")),
NotificationDelivery.due_at <= now,
)
.values(
state="leased",
lease_owner=owner,
lease_expires_at=now + timedelta(seconds=lease_seconds),
attempt_count=NotificationDelivery.attempt_count + 1,
)
)
if getattr(result, "rowcount", 0) != 1:
await db.rollback()
return None
delivery = await db.get(NotificationDelivery, delivery_id)
if delivery is None: # pragma: no cover - guarded by update
await db.rollback()
return None
subscription = await db.get(NotificationSubscription, delivery.subscription_id)
event = await db.get(NotificationEvent, delivery.event_id)
if subscription is None or event is None or subscription.state != "active":
delivery.state = "failed"
delivery.terminal_reason = "subscription_unavailable"
delivery.lease_owner = None
delivery.lease_expires_at = None
await db.commit()
return None
# Persist a token bucket before starting an attempt, so restarts cannot bypass
# the subscription rate limit. Global process rate is intentionally a config
# ceiling; the durable subscription bucket protects cross-restart behavior.
last = subscription.rate_updated_at or now
elapsed = max(0.0, (now - last).total_seconds())
capacity = subscription.rate_limit_per_minute
try:
token_capacity = float(capacity)
tokens = min(token_capacity, subscription.rate_tokens + elapsed * capacity / 60)
except (TypeError, ValueError, ZeroDivisionError) as error:
raise RuntimeError("notification rate limit is invalid") from error
if tokens < 1:
try:
delay = max(1, int((1 - tokens) * 60 / capacity) + 1)
except (TypeError, ValueError, ZeroDivisionError) as error:
raise RuntimeError("notification rate limit is invalid") from error
delivery.state = "retry"
delivery.due_at = now + timedelta(seconds=delay)
delivery.lease_owner = None
delivery.lease_expires_at = None
subscription.rate_tokens = tokens
subscription.rate_updated_at = now
await db.commit()
return None
subscription.rate_tokens = tokens - 1
subscription.rate_updated_at = now
db.add(
NotificationDeliveryAttempt(
delivery_id=delivery.id,
number=delivery.attempt_count,
started_at=now,
outcome="started",
)
)
await db.commit()
return delivery, subscription, event
async def _finish(
db: AsyncSession,
delivery_id: str,
owner: str,
*,
delivered: bool,
retryable: bool,
response_class: str,
reason: str,
retry_cap: int,
max_attempts: int,
) -> None:
delivery = await db.get(NotificationDelivery, delivery_id)
if delivery is None or delivery.lease_owner != owner or delivery.state != "leased":
await db.rollback()
return
attempt = await db.scalar(
select(NotificationDeliveryAttempt).where(
NotificationDeliveryAttempt.delivery_id == delivery.id,
NotificationDeliveryAttempt.number == delivery.attempt_count,
)
)
if attempt is None: # pragma: no cover - an invariant of _claim_due
await db.rollback()
return
now = datetime.now(UTC)
attempt.completed_at = now
attempt.response_class = response_class
attempt.diagnostic = reason[:512]
delivery.response_class = response_class
delivery.response_summary = reason[:512]
delivery.lease_owner = None
delivery.lease_expires_at = None
if delivered:
delivery.state = "delivered"
attempt.outcome = "delivered"
elif retryable and delivery.attempt_count < max_attempts:
delivery.state = "retry"
delivery.due_at = now + timedelta(seconds=retry_delay(delivery.attempt_count, retry_cap))
attempt.outcome = "retry"
else:
delivery.state = "failed"
delivery.terminal_reason = reason
attempt.outcome = "failed"
await db.commit()
async def dispatch_one(
db: AsyncSession,
settings: Settings,
cipher: EnvelopeCipher,
owner: str,
*,
resolver: Resolver = system_resolver,
) -> bool:
claimed = await _claim_due(db, owner, settings.notification_delivery_lease_seconds)
if claimed is None:
return False
delivery, subscription, event = claimed
max_attempts = settings.notification_max_attempts
try:
if subscription.channel == "webhook":
key_rows = list(
(
await db.scalars(
select(NotificationSigningKey).where(
NotificationSigningKey.subscription_id == subscription.id,
NotificationSigningKey.state.in_(("active", "overlap")),
)
)
).all()
)
keys: list[SigningMaterial] = []
now = datetime.now(UTC)
for key in key_rows:
if (
key.state == "overlap"
and key.overlap_expires_at is not None
and key.overlap_expires_at <= now
):
key.state = "retired"
continue
secret = await db.get(Secret, key.secret_id)
if secret is None:
raise WebhookTransportError("webhook signing secret is unavailable")
keys.append(
SigningMaterial(
key_id=key.id,
version=key.version,
secret=cipher.decrypt(
secret.ciphertext,
purpose=secret.purpose,
version=secret.version,
),
)
)
await db.commit()
webhook_result = await deliver_webhook(
str(subscription.destination_config["url"]),
event.canonical_envelope.encode(),
event_id=event.id,
event_type=event.type,
timestamp=event.occurred_at.isoformat(),
keys=keys,
resolver=resolver,
connect_timeout=settings.notification_connect_timeout_seconds,
read_timeout=settings.notification_read_timeout_seconds,
max_response_bytes=settings.notification_max_response_bytes,
)
decision = webhook_decision(webhook_result.status_code)
elif subscription.channel == "email":
email_settings = await db.get(NotificationEmailSettings, 1)
if email_settings is None:
raise EmailTransportError("SMTP settings are unavailable")
max_attempts = email_settings.max_attempts
password_secret = await db.get(Secret, email_settings.password_secret_id)
if password_secret is None:
raise EmailTransportError("SMTP password is unavailable")
email_result = await deliver_email(
email_settings,
cipher.decrypt(
password_secret.ciphertext,
purpose=password_secret.purpose,
version=password_secret.version,
),
event,
subscription.destination_config["recipients"],
)
decision = RetryDecision(False, email_result.response_class, "delivered")
else: # guarded by DB constraint
raise WebhookTransportError("notification channel is unavailable")
await _finish(
db,
delivery.id,
owner,
delivered=decision.reason == "delivered",
retryable=decision.retry,
response_class=decision.response_class,
reason=decision.reason,
retry_cap=settings.notification_retry_cap_seconds,
max_attempts=max_attempts,
)
except EmailTransportError as error:
decision = transport_decision(error.transient, "smtp_transport")
await _finish(
db,
delivery.id,
owner,
delivered=False,
retryable=decision.retry,
response_class=decision.response_class,
reason=str(error),
retry_cap=settings.notification_retry_cap_seconds,
max_attempts=max_attempts,
)
except WebhookTransportError as error:
decision = transport_decision(error.transient, "webhook_transport")
await _finish(
db,
delivery.id,
owner,
delivered=False,
retryable=decision.retry,
response_class=decision.response_class,
reason=str(error),
retry_cap=settings.notification_retry_cap_seconds,
max_attempts=max_attempts,
)
except (KeyError, ValueError):
decision = transport_decision(False, "webhook_validation")
await _finish(
db,
delivery.id,
owner,
delivered=False,
retryable=False,
response_class=decision.response_class,
reason=decision.reason,
retry_cap=settings.notification_retry_cap_seconds,
max_attempts=max_attempts,
)
return True
@@ -0,0 +1,120 @@
"""Authenticated, certificate-verified STARTTLS email notification transport."""
from __future__ import annotations
import asyncio
import smtplib
import ssl
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formataddr
from typing import Protocol, Self, cast
from backup_tool.db.models import NotificationEmailSettings, NotificationEvent
class SMTPClient(Protocol):
def __enter__(self) -> Self: ...
def __exit__(self, *args: object) -> None: ...
def ehlo(self) -> object: ...
def starttls(self, *, context: ssl.SSLContext) -> object: ...
def login(self, user: str, password: str) -> object: ...
def send_message(self, msg: EmailMessage) -> object: ...
class EmailTransportError(RuntimeError):
def __init__(self, message: str, *, transient: bool = False) -> None:
super().__init__(message)
self.transient = transient
@dataclass(frozen=True)
class EmailResult:
response_class: str
def validate_address(value: str) -> str:
if not value or len(value) > 320 or any(character in value for character in "\r\n"):
raise EmailTransportError("email address is invalid")
local, separator, domain = value.rpartition("@")
if not separator or not local or not domain or any(character.isspace() for character in value):
raise EmailTransportError("email address is invalid")
return value
def validate_recipients(values: Sequence[str]) -> list[str]:
if not values or len(values) > 20:
raise EmailTransportError("one to 20 email recipients are required")
recipients: list[str] = []
for value in values:
address = validate_address(value)
if address not in recipients:
recipients.append(address)
return recipients
def _message(
settings: NotificationEmailSettings,
event: NotificationEvent,
recipients: Sequence[str],
) -> EmailMessage:
sender = validate_address(settings.sender)
safe_recipients = validate_recipients(recipients)
message = EmailMessage()
message["From"] = formataddr(("Backup Tool", sender))
message["To"] = ", ".join(safe_recipients)
message["Subject"] = f"Backup Tool: {event.type} ({event.severity})"
message["X-Backup-Event-ID"] = event.id
# Do not put the full envelope, paths, raw errors, or credentials into mail.
message.set_content(
"Backup Tool operational event\n"
f"Event ID: {event.id}\n"
f"Type: {event.type}\n"
f"Severity: {event.severity}\n"
f"Occurred: {event.occurred_at.isoformat()}\n"
)
return message
def _deliver_sync(
settings: NotificationEmailSettings,
password: str,
event: NotificationEvent,
recipients: Sequence[str],
smtp_factory: Callable[..., SMTPClient],
) -> EmailResult:
message = _message(settings, event, recipients)
try:
with smtp_factory(settings.host, settings.port, timeout=10) as client:
client.ehlo()
context = ssl.create_default_context()
client.starttls(context=context)
client.ehlo()
client.login(settings.username, password)
client.send_message(message)
except smtplib.SMTPResponseException as error:
raise EmailTransportError(
f"smtp_{error.smtp_code}", transient=400 <= error.smtp_code < 500
) from error
except (smtplib.SMTPException, OSError) as error:
raise EmailTransportError("smtp_transport_failed", transient=True) from error
return EmailResult(response_class="smtp_2xx")
async def deliver_email(
settings: NotificationEmailSettings,
password: str,
event: NotificationEvent,
recipients: Sequence[str],
*,
smtp_factory: Callable[..., SMTPClient] | None = None,
) -> EmailResult:
"""Run blocking SMTP only in the worker thread, never in the web process."""
factory = smtp_factory or cast(Callable[..., SMTPClient], smtplib.SMTP)
return await asyncio.to_thread(_deliver_sync, settings, password, event, recipients, factory)
@@ -0,0 +1,279 @@
"""Versioned notification event catalog and transactional outbox fan-out."""
from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backup_tool.db.models import NotificationDelivery, NotificationEvent, NotificationSubscription
from backup_tool.ids import new_uuid7
from backup_tool.security.redaction import redact
from backup_tool.security.ssrf import SSRFError, validate_webhook_url
EVENT_SCHEMA_VERSION = 1
# Live-events-only policy: every public type below has a current production emitter.
_EVENT_TYPES = (
"execution.queued",
"execution.started",
"execution.committed",
"execution.failed",
"execution.cancelled",
"execution.retry_queued",
"execution.worker_recovered",
"schedule.created",
"schedule.updated",
"schedule.deleted",
"schedule.enabled",
"schedule.disabled",
"schedule.occurrence_enqueued",
"schedule.occurrence_misfired",
"schedule.occurrence_blocked",
"backup.committed",
"backup.verification_succeeded",
"restore.queued",
"restore.committed",
"restore.failed",
"retention.tombstoned",
"notification.test_requested",
)
EVENT_CATALOG: dict[str, dict[str, Any]] = {
event_type: {
"event_schema_version": EVENT_SCHEMA_VERSION,
"severity": "error" if event_type.endswith(("failed", "blocked", "rejected")) else "info",
"payload_keys": (
"attempt",
"count",
"dry_run",
"integrity",
"message",
"outcome",
"reason_code",
"requested_mode",
"effective_mode",
"state",
),
"reserved": False,
}
for event_type in _EVENT_TYPES
}
_SAFE_RESOURCE_KEYS = frozenset(
{
"execution_id",
"job_id",
"schedule_id",
"backup_id",
"restore_id",
"repository_id",
"subscription_id",
}
)
_SAFE_PAYLOAD_KEYS = frozenset().union(
*(set(spec["payload_keys"]) for spec in EVENT_CATALOG.values())
)
class NotificationEventError(ValueError):
"""A caller attempted to produce data outside the stable public catalog."""
def _as_uuid(value: str, field: str) -> str:
try:
parsed = UUID(value)
except (TypeError, ValueError, AttributeError) as error:
raise NotificationEventError(f"{field} must be a UUID") from error
return str(parsed)
def _safe_value(value: Any) -> Any:
if value is None or isinstance(value, (bool, int, float)):
return value
if isinstance(value, str):
return redact(value)[:512]
if isinstance(value, list):
if len(value) > 32:
raise NotificationEventError("payload arrays are limited to 32 values")
return [_safe_value(item) for item in value]
if isinstance(value, Mapping):
if len(value) > 32:
raise NotificationEventError("payload objects are limited to 32 fields")
return {str(key)[:64]: _safe_value(item) for key, item in value.items()}
raise NotificationEventError("payload contains an unsupported value")
def _is_filter_match(filter_value: str, event_type: str) -> bool:
if filter_value == event_type:
return True
family, wildcard = filter_value.rsplit(".", 1) if "." in filter_value else ("", "")
return wildcard == "*" and event_type.startswith(f"{family}.")
def validate_filters(filters: Sequence[str]) -> list[str]:
if not filters:
raise NotificationEventError("at least one event filter is required")
if len(filters) > len(EVENT_CATALOG):
raise NotificationEventError("too many event filters")
output: list[str] = []
for filter_value in filters:
if not isinstance(filter_value, str) or len(filter_value) > 96:
raise NotificationEventError("invalid event filter")
if filter_value.endswith(".*"):
family = filter_value[:-2]
if not family or not any(item.startswith(f"{family}.") for item in EVENT_CATALOG):
raise NotificationEventError("unknown event filter")
elif filter_value not in EVENT_CATALOG:
raise NotificationEventError("unknown event filter")
if filter_value not in output:
output.append(filter_value)
return output
def validate_destination(channel: str, destination: Mapping[str, Any]) -> dict[str, Any]:
resource_filters = destination.get("resource_filters", {})
if not isinstance(resource_filters, Mapping):
raise NotificationEventError("resource filters are invalid")
unknown = set(resource_filters) - {"job_ids", "repository_ids", "severities"}
if unknown:
raise NotificationEventError("resource filter is unknown")
normalized_filters: dict[str, list[str]] = {}
for key in ("job_ids", "repository_ids"):
values = resource_filters.get(key)
if values is None:
continue
if not isinstance(values, list) or not values:
raise NotificationEventError("resource filter is invalid")
normalized_filters[key] = [_as_uuid(value, key) for value in values]
severities = resource_filters.get("severities")
if severities is not None:
if not isinstance(severities, list) or not severities:
raise NotificationEventError("severity filter is invalid")
if any(value not in {"info", "warning", "error", "security"} for value in severities):
raise NotificationEventError("severity filter is invalid")
normalized_filters["severities"] = list(severities)
if channel == "webhook":
url = destination.get("url")
if not isinstance(url, str) or len(url) > 2048:
raise NotificationEventError("webhook URL is required")
try:
validate_webhook_url(url)
except SSRFError as error:
raise NotificationEventError("webhook URL is invalid") from error
return {"url": url, "resource_filters": normalized_filters}
if channel == "email":
recipients = destination.get("recipients")
if not isinstance(recipients, list) or not recipients or len(recipients) > 20:
raise NotificationEventError("one to 20 email recipients are required")
safe_recipients: list[str] = []
for recipient in recipients:
if not isinstance(recipient, str) or any(char in recipient for char in "\r\n"):
raise NotificationEventError("invalid email recipient")
if "@" not in recipient or len(recipient) > 320:
raise NotificationEventError("invalid email recipient")
if recipient not in safe_recipients:
safe_recipients.append(recipient)
return {"recipients": safe_recipients, "resource_filters": normalized_filters}
raise NotificationEventError("unsupported notification channel")
def _matches(subscription: NotificationSubscription, event: dict[str, Any]) -> bool:
if subscription.state != "active":
return False
if not any(_is_filter_match(item, str(event["type"])) for item in subscription.event_filters):
return False
filters = subscription.destination_config.get("resource_filters", {})
if not isinstance(filters, Mapping):
return False
resources = event["resource"]
for key in ("job_ids", "repository_ids"):
selected = filters.get(key)
resource_key = key[:-1]
if selected is not None and resources.get(resource_key) not in selected:
return False
severities = filters.get("severities")
return severities is None or event["severity"] in severities
async def emit_event(
db: AsyncSession,
event_type: str,
*,
correlation_id: str,
resource: Mapping[str, str] | None = None,
payload: Mapping[str, Any] | None = None,
severity: str | None = None,
deduplication_key: str | None = None,
occurred_at: datetime | None = None,
only_subscription_id: str | None = None,
) -> NotificationEvent:
"""Append an immutable event and matching deliveries; intentionally never commits."""
if event_type not in EVENT_CATALOG:
raise NotificationEventError("unknown operational event type")
correlation_id = _as_uuid(correlation_id, "correlation_id")
resource = resource or {}
if set(resource) - _SAFE_RESOURCE_KEYS:
raise NotificationEventError("unknown resource reference")
safe_resource = {key: _as_uuid(value, key) for key, value in resource.items()}
payload = payload or {}
if set(payload) - _SAFE_PAYLOAD_KEYS:
raise NotificationEventError("payload key is not allowlisted")
safe_payload = {key: _safe_value(value) for key, value in payload.items()}
event_severity = severity or str(EVENT_CATALOG[event_type]["severity"])
if event_severity not in {"info", "warning", "error", "security"}:
raise NotificationEventError("invalid severity")
if deduplication_key is not None and (not deduplication_key or len(deduplication_key) > 255):
raise NotificationEventError("invalid deduplication key")
if deduplication_key is not None:
existing = await db.scalar(
select(NotificationEvent).where(
NotificationEvent.deduplication_key == deduplication_key
)
)
if existing is not None:
return existing
event_id = str(new_uuid7())
timestamp = (occurred_at or datetime.now(UTC)).astimezone(UTC)
envelope = {
"event_schema_version": EVENT_SCHEMA_VERSION,
"id": event_id,
"type": event_type,
"occurred_at": timestamp.isoformat(),
"correlation_id": correlation_id,
"severity": event_severity,
"resource": safe_resource,
"payload": safe_payload,
}
event = NotificationEvent(
id=event_id,
type=event_type,
schema_version=EVENT_SCHEMA_VERSION,
occurred_at=timestamp,
correlation_id=correlation_id,
severity=event_severity,
resource_refs=safe_resource,
payload=safe_payload,
canonical_envelope=json.dumps(envelope, sort_keys=True, separators=(",", ":")),
deduplication_key=deduplication_key,
)
db.add(event)
await db.flush()
active_subscriptions = select(NotificationSubscription).where(
NotificationSubscription.state == "active"
)
subscriptions = list((await db.scalars(active_subscriptions)).all())
for subscription in subscriptions:
selected_for_test = only_subscription_id == subscription.id
if selected_for_test or (only_subscription_id is None and _matches(subscription, envelope)):
db.add(
NotificationDelivery(
event_id=event.id,
subscription_id=subscription.id,
due_at=timestamp,
)
)
await db.flush()
return event
@@ -0,0 +1,33 @@
"""Deterministic bounded retry classification for notification delivery."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class RetryDecision:
retry: bool
response_class: str
reason: str
def retry_delay(attempt: int, cap_seconds: int) -> int:
"""Bounded exponential delay; no jitter keeps durable tests/restarts deterministic."""
exponent = max(0, attempt - 1)
return min(cap_seconds, 1 << exponent)
def webhook_decision(status_code: int) -> RetryDecision:
if 200 <= status_code < 300:
return RetryDecision(False, "http_2xx", "delivered")
if status_code in {408, 425, 429} or status_code >= 500:
return RetryDecision(True, f"http_{status_code}", "http_transient")
if 300 <= status_code < 400:
return RetryDecision(False, f"http_{status_code}", "redirect_rejected")
return RetryDecision(False, f"http_{status_code}", "http_permanent")
def transport_decision(transient: bool, response_class: str) -> RetryDecision:
reason = "transport_transient" if transient else "transport_failed"
return RetryDecision(transient, response_class, reason)
@@ -0,0 +1,210 @@
"""Canonical, dual-key HMAC webhook requests on a DNS-pinned HTTPX transport."""
from __future__ import annotations
import asyncio
import hmac
import ssl
from collections.abc import Sequence
from dataclasses import dataclass
from hashlib import sha256
import httpx
from backup_tool.security.ssrf import (
ResolvedWebhookTarget,
Resolver,
SSRFError,
resolve_webhook_target,
verify_connected_peer,
)
SIGNATURE_VERSION = "v1"
class WebhookTransportError(RuntimeError):
def __init__(self, message: str, *, transient: bool = False) -> None:
super().__init__(message)
self.transient = transient
@dataclass(frozen=True)
class SigningMaterial:
key_id: str
version: int
secret: str
@dataclass(frozen=True)
class WebhookResult:
status_code: int
response_bytes: int
def canonical_signing_input(timestamp: str, body: bytes) -> bytes:
return SIGNATURE_VERSION.encode() + b"." + timestamp.encode("ascii") + b"." + body
def signatures(timestamp: str, body: bytes, keys: Sequence[SigningMaterial]) -> list[str]:
signing_input = canonical_signing_input(timestamp, body)
return [
f"{SIGNATURE_VERSION};key_id={key.key_id};key_version={key.version};sha256="
f"{hmac.new(key.secret.encode(), signing_input, sha256).hexdigest()}"
for key in keys
]
class PinnedWebhookTransport(httpx.AsyncBaseTransport):
"""HTTPX transport that never lets a post-validation DNS lookup choose a peer."""
def __init__(
self,
*,
target: ResolvedWebhookTarget,
connect_timeout: float,
read_timeout: float,
max_response_bytes: int,
) -> None:
self._target = target
self._connect_timeout = connect_timeout
self._read_timeout = read_timeout
self._max_response_bytes = max_response_bytes
async def _connect(
self, target: ResolvedWebhookTarget
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
hostname = target.url.hostname
if hostname is None: # guarded by resolve_webhook_target
raise WebhookTransportError("webhook hostname is unavailable")
context = ssl.create_default_context() if target.url.scheme == "https" else None
last_error: OSError | None = None
for address in target.addresses:
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(
address,
target.port,
ssl=context,
server_hostname=hostname if context is not None else None,
),
timeout=self._connect_timeout,
)
verify_connected_peer(writer.get_extra_info("peername"), target.addresses)
return reader, writer
except (TimeoutError, OSError, ssl.SSLError, SSRFError) as error:
last_error = error if isinstance(error, OSError) else None
raise WebhookTransportError("webhook connection failed", transient=True) from last_error
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
target = self._target
if str(request.url) != target.url.geturl():
raise WebhookTransportError("webhook target changed")
body = await request.aread()
if len(body) > 65_536:
raise WebhookTransportError("webhook body is too large")
reader, writer = await self._connect(target)
try:
raw_path = target.url.path or "/"
if target.url.query:
raw_path += "?" + target.url.query
headers = [(key, value) for key, value in request.headers.multi_items()]
header_names = {key.lower() for key, _ in headers}
if "host" not in header_names:
host = target.url.hostname or ""
headers.append(("Host", host))
if "content-length" not in header_names:
headers.append(("Content-Length", str(len(body))))
headers.append(("Connection", "close"))
serialized = [f"{request.method} {raw_path} HTTP/1.1\r\n".encode()]
serialized.extend(f"{key}: {value}\r\n".encode("ascii") for key, value in headers)
writer.write(b"".join(serialized) + b"\r\n" + body)
await asyncio.wait_for(writer.drain(), timeout=self._read_timeout)
head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=self._read_timeout)
if len(head) > 16_384:
raise WebhookTransportError("webhook response headers are too large")
lines = head.decode("iso-8859-1").split("\r\n")
try:
_protocol, code, _reason = lines[0].split(" ", 2)
status_code = int(code)
except (IndexError, ValueError) as error:
raise WebhookTransportError("webhook response is malformed") from error
response_headers: list[tuple[str, str]] = []
for line in lines[1:]:
if not line:
continue
key, separator, value = line.partition(":")
if not separator:
raise WebhookTransportError("webhook response headers are malformed")
response_headers.append((key.strip(), value.strip()))
content = await asyncio.wait_for(
reader.read(self._max_response_bytes + 1), timeout=self._read_timeout
)
if len(content) > self._max_response_bytes:
raise WebhookTransportError("webhook response is too large")
return httpx.Response(
status_code,
headers=response_headers,
content=content,
request=request,
)
except (TimeoutError, OSError, asyncio.IncompleteReadError) as error:
raise WebhookTransportError("webhook request failed", transient=True) from error
finally:
writer.close()
with __import__("contextlib").suppress(OSError):
await writer.wait_closed()
def webhook_headers(
event_id: str,
event_type: str,
timestamp: str,
body: bytes,
keys: Sequence[SigningMaterial],
) -> list[tuple[str, str]]:
headers: list[tuple[str, str]] = [
("Content-Type", "application/json"),
("X-Backup-Event-ID", event_id),
("X-Backup-Event-Type", event_type),
("X-Backup-Signature-Version", SIGNATURE_VERSION),
("X-Backup-Timestamp", timestamp),
]
headers.extend(("X-Backup-Signature", value) for value in signatures(timestamp, body, keys))
return headers
async def deliver_webhook(
url: str,
body: bytes,
*,
event_id: str,
event_type: str,
timestamp: str,
keys: Sequence[SigningMaterial],
resolver: Resolver,
connect_timeout: float,
read_timeout: float,
max_response_bytes: int,
) -> WebhookResult:
if not keys:
raise WebhookTransportError("webhook subscription has no active signing key")
try:
# Resolve immediately before this individual attempt. The resulting
# addresses are passed to the transport, so it cannot rebind at connect.
target = await resolve_webhook_target(url, resolver)
except SSRFError as error:
raise WebhookTransportError("webhook_target_rejected") from error
transport = PinnedWebhookTransport(
target=target,
connect_timeout=connect_timeout,
read_timeout=read_timeout,
max_response_bytes=max_response_bytes,
)
headers = webhook_headers(event_id, event_type, timestamp, body, keys)
async with httpx.AsyncClient(
transport=transport, follow_redirects=False, trust_env=False
) as client:
response = await client.post(url, content=body, headers=headers)
if 300 <= response.status_code < 400:
raise WebhookTransportError("redirect_rejected")
return WebhookResult(status_code=response.status_code, response_bytes=len(response.content))
@@ -0,0 +1 @@
"""Operational logging, metrics, and readiness primitives."""
@@ -0,0 +1,39 @@
"""Readiness checks shared by HTTP and background process roles."""
from __future__ import annotations
import os
from backup_tool.cli import build_alembic_config
from backup_tool.config import Settings
from backup_tool.db.engine import assert_schema_current, create_engine
class ReadinessError(RuntimeError):
"""A dependency needed by the selected role is unavailable."""
def _require_access(path: object, mode: int) -> None:
try:
candidate = path if isinstance(path, str) else str(path)
if not os.path.isdir(candidate) or not os.access(candidate, mode):
raise ReadinessError("required storage is unavailable")
except OSError as error:
raise ReadinessError("required storage is unavailable") from error
async def check_role_readiness(settings: Settings, role: str) -> None:
engine = create_engine(settings)
try:
await assert_schema_current(engine, build_alembic_config(settings))
except Exception as error:
raise ReadinessError("metadata is unavailable") from error
finally:
await engine.dispose()
if role == "scheduler":
return
_require_access(settings.data_dir, os.R_OK | os.W_OK | os.X_OK)
for root in settings.repository_roots + settings.restore_roots:
_require_access(root, os.R_OK | os.W_OK | os.X_OK)
for root in settings.local_source_roots:
_require_access(root, os.R_OK | os.X_OK)
@@ -0,0 +1,39 @@
"""JSON logging that keeps operational context machine-readable and secret-free."""
from __future__ import annotations
import json
import logging
import sys
from datetime import UTC, datetime
from typing import Any
_STANDARD_RECORD_KEYS = frozenset(logging.makeLogRecord({}).__dict__)
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"event": record.getMessage(),
"level": record.levelname.lower(),
"logger": record.name,
"timestamp": datetime.now(UTC).isoformat(),
}
for key, value in record.__dict__.items():
if key not in _STANDARD_RECORD_KEYS and key not in {"message", "asctime"}:
payload[key] = value
return json.dumps(payload, default=str, separators=(",", ":"), sort_keys=True)
def configure_logging(role: str, level: str) -> None:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(level)
logging.getLogger("backup_tool").info("role_started", extra={"role": role})
def log_event(name: str, **fields: object) -> None:
logging.getLogger("backup_tool").info(name, extra=fields)
@@ -0,0 +1,102 @@
"""Small dependency-free Prometheus exposition for the single-node appliance."""
from __future__ import annotations
import os
import threading
from collections import defaultdict
from collections.abc import Iterable
from datetime import UTC, datetime
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backup_tool.config import Settings
from backup_tool.db.models import Backup, Execution, Repository, Schedule
from backup_tool.execution import ACTIVE_STATES
class Metrics:
def __init__(self) -> None:
self._lock = threading.Lock()
self._requests: dict[tuple[str, str, int], int] = defaultdict(int)
self._durations: dict[tuple[str, str], tuple[int, float]] = {}
def observe_request(self, method: str, path: str, status: int, duration_seconds: float) -> None:
route = path if path in {"/livez", "/readyz", "/metrics"} else "/api"
with self._lock:
self._requests[(method, route, status)] += 1
count, total = self._durations.get((method, route), (0, 0.0))
self._durations[(method, route)] = (count + 1, total + duration_seconds)
def render(self, operational: Iterable[tuple[str, float]]) -> str:
lines = [
"# HELP backup_tool_http_requests_total HTTP requests handled by the web role.",
"# TYPE backup_tool_http_requests_total counter",
]
with self._lock:
for (method, path, status), value in sorted(self._requests.items()):
labels = f'method="{method}",path="{path}",status="{status}"'
lines.append(f"backup_tool_http_requests_total{{{labels}}} {value}")
lines.extend(
[
"# HELP backup_tool_http_request_duration_seconds HTTP request duration.",
"# TYPE backup_tool_http_request_duration_seconds summary",
]
)
for (method, path), (count, total) in sorted(self._durations.items()):
labels = f'method="{method}",path="{path}"'
lines.append(f"backup_tool_http_request_duration_seconds_count{{{labels}}} {count}")
lines.append(
f"backup_tool_http_request_duration_seconds_sum{{{labels}}} {total:.6f}"
)
lines.extend(f"{name} {value}" for name, value in operational)
return "\n".join(lines) + "\n"
async def collect_operational_metrics(
settings: Settings, db: AsyncSession
) -> list[tuple[str, float]]:
now = datetime.now(UTC)
active = await db.scalar(
select(func.count()).select_from(Execution).where(Execution.state.in_(ACTIVE_STATES))
)
stale = await db.scalar(
select(func.count())
.select_from(Execution)
.where(Execution.lease_expires_at.is_not(None), Execution.lease_expires_at < now)
)
failed = await db.scalar(
select(func.count()).select_from(Execution).where(Execution.state == "failed")
)
corrupt = await db.scalar(
select(func.count()).select_from(Backup).where(Backup.integrity == "corrupt")
)
schedule_lag = await db.scalar(
select(func.min(Schedule.next_nominal_at)).where(
Schedule.enabled, Schedule.next_nominal_at.is_not(None)
)
)
values = [
("backup_tool_active_executions", active or 0),
("backup_tool_stale_execution_leases", stale or 0),
("backup_tool_failed_executions", failed or 0),
("backup_tool_corrupt_backups", corrupt or 0),
(
"backup_tool_schedule_lag_seconds",
max(0.0, (now - schedule_lag).total_seconds()) if schedule_lag is not None else 0.0,
),
]
roots = list(settings.repository_roots) + list(settings.restore_roots)
for index, root in enumerate(roots):
try:
stats = os.statvfs(root)
except OSError:
continue
name = f'backup_tool_filesystem_free_bytes{{root="{index}"}}'
values.append((name, stats.f_bavail * stats.f_frsize))
unavailable = await db.scalar(
select(func.count()).select_from(Repository).where(Repository.state == "unavailable")
)
values.append(("backup_tool_unavailable_repositories", unavailable or 0))
return values
+367 -10
View File
@@ -4,12 +4,19 @@ import hashlib
import json
import os
import shutil
import stat
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from sqlalchemy.ext.asyncio import AsyncSession
from backup_tool.config import Settings
from backup_tool.ids import new_uuid7
from backup_tool.security.repository_crypto import RepositoryKeyError, create_data_key
class RepositoryError(ValueError):
@@ -19,9 +26,15 @@ class RepositoryError(ValueError):
@dataclass(frozen=True)
class InitializedRepository:
root: Path
repository_id: str
format_version: int = 1
compression: str = "none"
encryption: str = "none"
signing_key_id: str = ""
signing_public_key: str = ""
signing_key_path: Path | None = None
data_key_id: str | None = None
data_key_path: Path | None = None
def blob_digest(content: bytes) -> str:
@@ -47,14 +60,14 @@ def _contained(root: Path, relative_path: str) -> Path:
def _canonical_payload(compression: str, encryption: str) -> dict[str, object]:
if compression != "none" or encryption != "none":
if compression != "none" or encryption not in {"none", "aes-256-gcm"}:
raise RepositoryError("requested repository policy is unavailable")
return {
"repository_id": str(new_uuid7()),
"format_version": 1,
"digest_algorithm": "sha256",
"compression": compression,
"encryption": {"mode": "none", "key_id": None},
"encryption": {"mode": encryption, "key_id": None},
"created_at": datetime.now(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z"),
}
@@ -63,7 +76,205 @@ def _canonical_json(payload: dict[str, object]) -> str:
return json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n"
def _assert_capacity(settings: Settings, root: Path) -> None:
def replace_active_data_key(root: Path, expected_key_id: str, new_key_id: str) -> None:
"""Durably replace the active, non-secret epoch reference in repository metadata."""
metadata = root / "repository.json"
try:
payload = json.loads(metadata.read_text(encoding="utf-8"))
encryption = payload.get("encryption") if isinstance(payload, dict) else None
if (
not isinstance(encryption, dict)
or encryption.get("mode") != "aes-256-gcm"
or encryption.get("key_id") != expected_key_id
):
raise ValueError
except (OSError, ValueError, json.JSONDecodeError) as error:
raise RepositoryError("repository metadata is invalid") from error
encryption["key_id"] = new_key_id
staging = root / f".repository.json.{os.urandom(8).hex()}.tmp"
try:
descriptor = os.open(staging, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
handle.write(_canonical_json(payload))
handle.flush()
os.fsync(handle.fileno())
os.replace(staging, metadata)
descriptor = os.open(root, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
except OSError as error:
raise RepositoryError("repository metadata cannot be updated") from error
finally:
staging.unlink(missing_ok=True)
_ROTATION_JOURNAL = ".key-rotation.json"
def begin_key_rotation(
root: Path,
repository_database_id: str,
repository_id: str,
old_key_id: str,
new_key_id: str,
) -> None:
"""Persist an intent record before changing durable key-epoch state."""
if not all((repository_database_id, repository_id, old_key_id, new_key_id)):
raise RepositoryError("repository rotation journal is invalid")
journal = root / _ROTATION_JOURNAL
payload: dict[str, object] = {
"new_key_id": new_key_id,
"old_key_id": old_key_id,
"repository_database_id": repository_database_id,
"repository_id": repository_id,
"version": 1,
}
try:
descriptor = os.open(journal, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
handle.write(_canonical_json(payload))
handle.flush()
os.fsync(handle.fileno())
descriptor = os.open(root, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
except OSError as error:
raise RepositoryError("repository rotation is already in progress") from error
def read_key_rotation(root: Path) -> dict[str, str] | None:
journal = root / _ROTATION_JOURNAL
if not journal.exists():
return None
try:
if journal.is_symlink() or stat.S_IMODE(journal.stat().st_mode) != 0o600:
raise ValueError
payload = json.loads(journal.read_text(encoding="utf-8"))
expected = {
"new_key_id",
"old_key_id",
"repository_database_id",
"repository_id",
"version",
}
if (
not isinstance(payload, dict)
or set(payload) != expected
or payload.get("version") != 1
or not all(
isinstance(payload[key], str) and payload[key] for key in expected - {"version"}
)
):
raise ValueError
return {key: payload[key] for key in expected - {"version"}}
except (OSError, ValueError, json.JSONDecodeError) as error:
raise RepositoryError("repository rotation journal is invalid") from error
def finish_key_rotation(root: Path) -> None:
journal = root / _ROTATION_JOURNAL
try:
journal.unlink()
descriptor = os.open(root, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
except OSError as error:
raise RepositoryError("repository rotation journal cannot be cleared") from error
async def reconcile_key_rotations(settings: Settings, db: AsyncSession) -> int:
"""Converge a journaled key rotation after a process crash.
A rotation whose DB transaction did not commit remains on the old active
epoch. A committed transaction deterministically advances repository.json.
"""
from sqlalchemy import select
from backup_tool.db.models import Repository, RepositoryDataKeyEpoch
from backup_tool.security.repository_crypto import load_data_key
repositories = list((await db.scalars(select(Repository))).all())
reconciled = 0
for repository in repositories:
if repository.encryption != "aes-256-gcm":
continue
root = Path(repository.root)
journal = read_key_rotation(root)
if journal is None:
continue
inspected = inspect_repository(settings, root)
if (
journal["repository_database_id"] != repository.id
or journal["repository_id"] != inspected.repository_id
or repository.active_data_key_id is None
):
raise RepositoryError("repository rotation journal does not match metadata")
epochs = list(
(
await db.scalars(
select(RepositoryDataKeyEpoch).where(
RepositoryDataKeyEpoch.repository_id == repository.id
)
)
).all()
)
active = [epoch for epoch in epochs if epoch.state == "active"]
old_key_id = journal["old_key_id"]
new_key_id = journal["new_key_id"]
if len(active) != 1:
raise RepositoryError("repository rotation epochs are invalid")
if repository.active_data_key_id == old_key_id and active[0].key_id == old_key_id:
if inspected.data_key_id != old_key_id:
raise RepositoryError("repository rotation metadata is invalid")
new_path = (
settings.data_dir
/ "repository-data-keys"
/ (f"{inspected.repository_id}.{new_key_id}.key")
)
# Clear the durable intent before deleting an unreferenced key. A
# crash afterward leaves only an orphaned key, not a journal whose
# retry depends on a key that no longer exists.
if new_path.exists() or new_path.is_symlink():
try:
load_data_key(settings, inspected.repository_id, new_key_id)
except RepositoryKeyError as error:
raise RepositoryError("repository rotation key is unavailable") from error
finish_key_rotation(inspected.root)
if new_path.exists() or new_path.is_symlink():
# The journal is already gone, so a cleanup failure only leaves
# an unreferenced key and must not disable the old-active epoch.
with suppress(OSError):
new_path.unlink()
descriptor = os.open(new_path.parent, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
reconciled += 1
continue
if repository.active_data_key_id == new_key_id and active[0].key_id == new_key_id:
try:
load_data_key(settings, inspected.repository_id, new_key_id)
except RepositoryKeyError as error:
raise RepositoryError("repository rotation key is unavailable") from error
if inspected.data_key_id == old_key_id:
replace_active_data_key(inspected.root, old_key_id, new_key_id)
elif inspected.data_key_id != new_key_id:
raise RepositoryError("repository rotation metadata is invalid")
finish_key_rotation(inspected.root)
reconciled += 1
continue
raise RepositoryError("repository rotation state is invalid")
return reconciled
def assert_capacity(settings: Settings, root: Path) -> None:
capacity_root = root
while not capacity_root.exists():
parent = capacity_root.parent
@@ -76,13 +287,57 @@ def _assert_capacity(settings: Settings, root: Path) -> None:
raise RepositoryError("repository root does not meet minimum free capacity")
def _signing_key_directory(settings: Settings) -> Path:
directory = settings.data_dir / "repository-keys"
directory.mkdir(mode=0o700, exist_ok=True)
if directory.is_symlink() or not directory.is_dir():
raise RepositoryError("repository signing key directory is unsafe")
if stat.S_IMODE(directory.stat().st_mode) != 0o700:
raise RepositoryError("repository signing key directory permissions must be 0700")
return directory
def _write_private_key_staging(directory: Path, repository_id: str, private_key: bytes) -> Path:
staging = directory / f".{repository_id}.{os.urandom(8).hex()}.tmp"
descriptor = os.open(staging, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "wb") as handle:
handle.write(private_key)
handle.flush()
os.fsync(handle.fileno())
return staging
def initialize(
settings: Settings, relative_path: str, compression: str, encryption: str
) -> InitializedRepository:
payload = _canonical_payload(compression, encryption)
repository_id = str(payload["repository_id"])
data_key_id: str | None = None
data_key_path: Path | None = None
if encryption == "aes-256-gcm":
try:
data_key_id, data_key_path = create_data_key(settings, repository_id)
except RepositoryKeyError as error:
raise RepositoryError("repository data key is unavailable") from error
payload["encryption"] = {"mode": encryption, "key_id": data_key_id}
private_key = Ed25519PrivateKey.generate()
private_bytes = private_key.private_bytes(
serialization.Encoding.Raw,
serialization.PrivateFormat.Raw,
serialization.NoEncryption(),
)
public_bytes = private_key.public_key().public_bytes(
serialization.Encoding.Raw,
serialization.PublicFormat.Raw,
)
signing_key_id = f"ed25519-{hashlib.sha256(public_bytes).hexdigest()[:32]}"
key_directory = _signing_key_directory(settings)
key_path = key_directory / f"{repository_id}.ed25519"
key_staging = _write_private_key_staging(key_directory, repository_id, private_bytes)
root = _contained(settings.repository_roots[0], relative_path)
_assert_capacity(settings, root.parent)
assert_capacity(settings, root.parent)
if root.exists():
key_staging.unlink(missing_ok=True)
raise RepositoryError("repository path already exists")
staging = root.with_name(f".{root.name}.staging-{os.urandom(8).hex()}")
try:
@@ -94,17 +349,106 @@ def initialize(
with metadata.open("rb") as handle:
os.fsync(handle.fileno())
os.replace(staging, root)
os.replace(key_staging, key_path)
except Exception:
shutil.rmtree(staging, ignore_errors=True)
shutil.rmtree(root, ignore_errors=True)
key_staging.unlink(missing_ok=True)
key_path.unlink(missing_ok=True)
if data_key_path is not None:
data_key_path.unlink(missing_ok=True)
raise
return InitializedRepository(root=root, compression=compression, encryption=encryption)
return InitializedRepository(
root=root,
repository_id=repository_id,
compression=compression,
encryption=encryption,
signing_key_id=signing_key_id,
signing_public_key=public_bytes.hex(),
signing_key_path=key_path,
data_key_id=data_key_id,
data_key_path=data_key_path,
)
def remove_repository(root: Path) -> None:
try:
def remove_repository(
root: Path,
signing_key_path: Path | None = None,
data_key_path: Path | None = None,
) -> None:
try: # noqa: SIM105 - cleanup must not race a concurrent remover
shutil.rmtree(root)
except FileNotFoundError:
return
pass
for key_path in (signing_key_path, data_key_path):
if key_path is None:
continue
try: # noqa: SIM105 - cleanup must not race a concurrent remover
key_path.unlink()
except FileNotFoundError:
pass
def install_signing_key(
settings: Settings,
repository_id: str,
expected_key_id: str,
expected_public_key: str,
private_bytes: bytes,
) -> Path:
"""Install a recovered signing key once after checking its public trust anchor."""
if len(private_bytes) != 32 or "/" in repository_id:
raise RepositoryError("repository signing key is invalid")
try:
private_key = Ed25519PrivateKey.from_private_bytes(private_bytes)
public_bytes = private_key.public_key().public_bytes(
serialization.Encoding.Raw,
serialization.PublicFormat.Raw,
)
except ValueError as error:
raise RepositoryError("repository signing key is invalid") from error
key_id = f"ed25519-{hashlib.sha256(public_bytes).hexdigest()[:32]}"
if key_id != expected_key_id or public_bytes.hex() != expected_public_key:
raise RepositoryError("repository signing key is invalid")
path = _signing_key_directory(settings) / f"{repository_id}.ed25519"
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(path, flags, 0o600)
with os.fdopen(descriptor, "wb") as handle:
handle.write(private_bytes)
handle.flush()
os.fsync(handle.fileno())
if path.is_symlink() or stat.S_IMODE(path.stat().st_mode) != 0o600:
path.unlink(missing_ok=True)
raise RepositoryError("repository signing key is unsafe")
except OSError as error:
raise RepositoryError("repository signing key cannot be installed") from error
return path
def load_signing_key(
settings: Settings,
repository_id: str,
expected_key_id: str,
expected_public_key: str,
) -> Ed25519PrivateKey:
key_path = _signing_key_directory(settings) / f"{repository_id}.ed25519"
try:
if key_path.is_symlink() or stat.S_IMODE(key_path.stat().st_mode) != 0o600:
raise RepositoryError("repository signing key is unsafe")
private_key = Ed25519PrivateKey.from_private_bytes(key_path.read_bytes())
except (OSError, ValueError) as error:
raise RepositoryError("repository signing key is unreadable") from error
public_key = private_key.public_key().public_bytes(
serialization.Encoding.Raw,
serialization.PublicFormat.Raw,
)
key_id = f"ed25519-{hashlib.sha256(public_key).hexdigest()[:32]}"
if key_id != expected_key_id or public_key.hex() != expected_public_key:
raise RepositoryError("repository signing key does not match its trust anchor")
return private_key
def inspect_repository(settings: Settings, root: Path) -> InitializedRepository:
@@ -140,7 +484,14 @@ def inspect_repository(settings: Settings, root: Path) -> InitializedRepository:
):
raise RepositoryError("repository metadata is not canonical")
encryption = payload.get("encryption")
if payload.get("compression") != "none" or encryption != {"mode": "none", "key_id": None}:
if payload.get("compression") != "none" or not isinstance(encryption, dict):
raise RepositoryError("repository metadata is invalid")
mode = encryption.get("mode")
key_id = encryption.get("key_id")
valid_policy = (mode == "none" and key_id is None) or (
mode == "aes-256-gcm" and isinstance(key_id, str) and bool(key_id)
)
if not valid_policy:
raise RepositoryError("repository metadata is invalid")
try:
from uuid import UUID
@@ -152,4 +503,10 @@ def inspect_repository(settings: Settings, root: Path) -> InitializedRepository:
datetime.fromisoformat(created_at[:-1] + "+00:00")
except (ValueError, TypeError) as error:
raise RepositoryError("repository metadata is invalid") from error
return InitializedRepository(root=resolved_root, compression="none", encryption="none")
return InitializedRepository(
root=resolved_root,
repository_id=str(payload["repository_id"]),
compression="none",
encryption=str(encryption["mode"]),
data_key_id=key_id if isinstance(key_id, str) else None,
)
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Protocol
class RetentionError(ValueError):
pass
class BackupLike(Protocol):
id: str
created_at: datetime
pinned: bool
tombstoned_at: datetime | None
@dataclass(frozen=True)
class RetentionPolicy:
keep_last: int = 1
keep_days: int = 0
keep_daily: int = 0
keep_weekly: int = 0
keep_monthly: int = 0
@classmethod
def from_dict(cls, value: dict[str, object]) -> RetentionPolicy:
allowed = {"keep_last", "keep_days", "keep_daily", "keep_weekly", "keep_monthly"}
if set(value) - allowed:
raise RetentionError("retention policy contains unknown keys")
kwargs: dict[str, int] = {}
for key in allowed:
raw = value.get(key, 0 if key != "keep_last" else 1)
if not isinstance(raw, int) or isinstance(raw, bool) or raw < 0:
raise RetentionError("retention values must be non-negative integers")
kwargs[key] = raw
return cls(**kwargs)
def retained_ids(
backups: Iterable[BackupLike], policy: RetentionPolicy, now: datetime | None = None
) -> set[str]:
"""Return union retention set; the newest non-tombstoned backup is always protected."""
reference = (now or datetime.now(UTC)).astimezone(UTC)
items = sorted(
(item for item in backups if item.tombstoned_at is None),
key=lambda item: item.created_at.astimezone(UTC),
reverse=True,
)
if not items:
return set()
kept = {items[0].id}
kept.update(item.id for item in items[: policy.keep_last])
kept.update(item.id for item in items if item.pinned)
if policy.keep_days:
cutoff = reference - timedelta(days=policy.keep_days)
kept.update(item.id for item in items if item.created_at.astimezone(UTC) >= cutoff)
for count, key in (
(policy.keep_daily, lambda stamp: stamp.date()),
(policy.keep_weekly, lambda stamp: stamp.isocalendar()[:2]),
(policy.keep_monthly, lambda stamp: (stamp.year, stamp.month)),
):
buckets: set[object] = set()
for item in items:
stamp = item.created_at.astimezone(UTC)
bucket = key(stamp)
if len(buckets) >= count and bucket not in buckets:
continue
buckets.add(bucket)
if bucket in buckets:
kept.add(item.id)
return kept
+140
View File
@@ -0,0 +1,140 @@
from __future__ import annotations
import asyncio
import contextlib
import signal
from datetime import UTC, datetime
from typing import cast
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from apscheduler.triggers.cron import CronTrigger # type: ignore[import-untyped]
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from backup_tool.config import Settings
from backup_tool.db.engine import create_engine
from backup_tool.db.models import Schedule
from backup_tool.execution import EnqueueError, enqueue
from backup_tool.notifications.events import emit_event
from backup_tool.observability.logging import configure_logging, log_event
class ScheduleError(ValueError):
pass
def next_nominal(cron: str, timezone: str, after: datetime | None = None) -> datetime:
if len(cron.split()) != 5:
raise ScheduleError("cron must contain exactly five fields")
try:
zone = ZoneInfo(timezone)
trigger = CronTrigger.from_crontab(cron, timezone=zone)
except (ValueError, ZoneInfoNotFoundError) as error:
raise ScheduleError("cron or timezone is invalid") from error
reference = (after or datetime.now(UTC)).astimezone(zone)
next_run = trigger.get_next_fire_time(None, reference)
if next_run is None:
raise ScheduleError("cron has no future occurrence")
return cast(datetime, next_run.astimezone(UTC))
class SchedulerService:
"""Dedicated scheduler role using the same transactional enqueue service."""
def __init__(self, settings: Settings) -> None:
self.engine = create_engine(settings)
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
self._stopping = asyncio.Event()
async def run_once(self) -> int:
async with self.sessions() as db:
return await deliver_due(db)
async def run(self) -> None:
while not self._stopping.is_set():
await self.run_once()
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(self._stopping.wait(), timeout=0.25)
await self.engine.dispose()
def stop(self) -> None:
log_event("role_stopping", role="scheduler")
self._stopping.set()
def run_scheduler(settings: Settings) -> int:
configure_logging("scheduler", settings.log_level)
service = SchedulerService(settings)
loop = asyncio.new_event_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, service.stop)
try:
loop.run_until_complete(service.run())
finally:
loop.close()
log_event("role_stopped", role="scheduler")
return 0
async def deliver_due(db: AsyncSession, now: datetime | None = None) -> int:
current = now or datetime.now(UTC)
schedules = list(
(
await db.scalars(
select(Schedule).where(
Schedule.enabled,
Schedule.next_nominal_at.is_not(None),
Schedule.next_nominal_at <= current,
)
)
).all()
)
delivered = 0
for schedule in schedules:
nominal = schedule.next_nominal_at
if nominal is None:
continue
schedule.next_nominal_at = next_nominal(schedule.cron, schedule.timezone, nominal)
if (current - nominal).total_seconds() > schedule.misfire_grace_seconds:
schedule.last_enqueue_outcome = "misfire"
await emit_event(
db,
"schedule.occurrence_misfired",
correlation_id=schedule.id,
resource={"schedule_id": schedule.id, "job_id": schedule.job_id},
payload={"outcome": "misfire"},
deduplication_key=f"schedule:{schedule.id}:nominal:{nominal.isoformat()}:misfire",
)
continue
try:
await enqueue(
db,
schedule.job_id,
"schedule",
schedule_id=schedule.id,
nominal_run_at=nominal,
)
except EnqueueError as error:
schedule.last_enqueue_outcome = error.code
await emit_event(
db,
"schedule.occurrence_blocked",
correlation_id=schedule.id,
resource={"schedule_id": schedule.id, "job_id": schedule.job_id},
payload={"reason_code": error.code},
deduplication_key=f"schedule:{schedule.id}:nominal:{nominal.isoformat()}:blocked",
)
else:
schedule.last_enqueue_outcome = "enqueued"
await emit_event(
db,
"schedule.occurrence_enqueued",
correlation_id=schedule.id,
resource={"schedule_id": schedule.id, "job_id": schedule.job_id},
payload={"outcome": "enqueued"},
deduplication_key=f"schedule:{schedule.id}:nominal:{nominal.isoformat()}:enqueued",
)
delivered += 1
await db.commit()
return delivered
@@ -0,0 +1,249 @@
"""Offline, passphrase-protected recovery bundle codec.
The binary format is deliberately small and versioned so validation can reject
unsupported inputs before attempting expensive password derivation. Every
failure while parsing or authenticating a bundle is reported as the same error
so callers cannot distinguish a malformed bundle from a wrong passphrase.
"""
from __future__ import annotations
import json
import os
import stat
import struct
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from argon2.low_level import Type, hash_secret_raw
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
class RecoveryBundleError(ValueError):
"""A non-disclosing recovery bundle validation failure."""
class RecoveryBundlePathError(ValueError):
"""A requested recovery bundle path cannot be used safely."""
_MAGIC = b"BTREC"
_VERSION = 1
_KDF_ARGON2ID = 1
_SALT_BYTES = 16
_NONCE_BYTES = 12
_KEY_BYTES = 32
_TAG_BYTES = 16
_TIME_COST = 3
_MEMORY_COST_KIB = 65_536
_PARALLELISM = 1
_MAX_PASSPHRASE_BYTES = 4_096
_MAX_PLAINTEXT_BYTES = 8 * 1024 * 1024
# magic, version, KDF id, Argon2 time/memory/parallelism, salt/nonce lengths,
# and the AES-GCM ciphertext (including tag) length.
_HEADER = struct.Struct(">5sBBIIHBBQ")
_MAX_BUNDLE_BYTES = _HEADER.size + _SALT_BYTES + _NONCE_BYTES + _MAX_PLAINTEXT_BYTES + _TAG_BYTES
_ERROR = "recovery bundle is invalid"
def _invalid() -> RecoveryBundleError:
return RecoveryBundleError(_ERROR)
def _canonical_json(payload: Mapping[str, Any]) -> bytes:
try:
encoded = json.dumps(
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode("utf-8")
except (TypeError, ValueError) as error:
raise _invalid() from error
if not encoded or len(encoded) > _MAX_PLAINTEXT_BYTES:
raise _invalid()
return encoded
def _passphrase(value: bytes) -> bytes:
if not isinstance(value, bytes) or not value or len(value) > _MAX_PASSPHRASE_BYTES:
raise _invalid()
return value
def _derive_key(passphrase: bytes, salt: bytes) -> bytes:
return hash_secret_raw(
secret=passphrase,
salt=salt,
time_cost=_TIME_COST,
memory_cost=_MEMORY_COST_KIB,
parallelism=_PARALLELISM,
hash_len=_KEY_BYTES,
type=Type.ID,
)
def encrypt_bundle(payload: Mapping[str, Any], passphrase: bytes) -> bytes:
"""Serialize and encrypt a canonical recovery payload as a BTREC v1 bundle."""
plaintext = _canonical_json(payload)
secret = _passphrase(passphrase)
salt = os.urandom(_SALT_BYTES)
nonce = os.urandom(_NONCE_BYTES)
ciphertext_length = len(plaintext) + _TAG_BYTES
header = _HEADER.pack(
_MAGIC,
_VERSION,
_KDF_ARGON2ID,
_TIME_COST,
_MEMORY_COST_KIB,
_PARALLELISM,
_SALT_BYTES,
_NONCE_BYTES,
ciphertext_length,
)
ciphertext = AESGCM(_derive_key(secret, salt)).encrypt(nonce, plaintext, header)
return header + salt + nonce + ciphertext
def decrypt_bundle(encoded: bytes, passphrase: bytes) -> dict[str, Any]:
"""Authenticate and decode a BTREC v1 bundle without disclosing failure cause."""
try:
if not isinstance(encoded, bytes) or len(encoded) > _MAX_BUNDLE_BYTES:
raise _invalid()
if len(encoded) < _HEADER.size + _SALT_BYTES + _NONCE_BYTES + _TAG_BYTES:
raise _invalid()
(
magic,
version,
kdf_id,
time_cost,
memory_cost,
parallelism,
salt_length,
nonce_length,
ciphertext_length,
) = _HEADER.unpack(encoded[: _HEADER.size])
if (
magic != _MAGIC
or version != _VERSION
or kdf_id != _KDF_ARGON2ID
or time_cost != _TIME_COST
or memory_cost != _MEMORY_COST_KIB
or parallelism != _PARALLELISM
or salt_length != _SALT_BYTES
or nonce_length != _NONCE_BYTES
or ciphertext_length < _TAG_BYTES
or ciphertext_length > _MAX_PLAINTEXT_BYTES + _TAG_BYTES
or len(encoded) != _HEADER.size + salt_length + nonce_length + ciphertext_length
):
raise _invalid()
secret = _passphrase(passphrase)
salt_start = _HEADER.size
nonce_start = salt_start + salt_length
ciphertext_start = nonce_start + nonce_length
plaintext = AESGCM(_derive_key(secret, encoded[salt_start:nonce_start])).decrypt(
encoded[nonce_start:ciphertext_start],
encoded[ciphertext_start:],
encoded[: _HEADER.size],
)
if not plaintext or len(plaintext) > _MAX_PLAINTEXT_BYTES:
raise _invalid()
payload = json.loads(plaintext.decode("utf-8"))
if not isinstance(payload, dict):
raise _invalid()
# Reject non-canonical encodings to make catalog serialization deterministic.
if _canonical_json(payload) != plaintext:
raise _invalid()
return payload
except (
InvalidTag,
UnicodeDecodeError,
json.JSONDecodeError,
struct.error,
ValueError,
) as error:
if isinstance(error, RecoveryBundleError):
raise error
raise _invalid() from error
def _check_path_components(path: Path) -> None:
if not path.is_absolute() or path.name in {"", ".", ".."}:
raise RecoveryBundlePathError("recovery bundle path is unsafe")
current = Path(path.anchor)
for component in path.parts[1:-1]:
current /= component
try:
info = current.lstat()
except OSError as error:
raise RecoveryBundlePathError("recovery bundle path is unsafe") from error
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
raise RecoveryBundlePathError("recovery bundle path is unsafe")
def write_bundle_exclusive(path: Path, encoded: bytes) -> None:
"""Write a bundle once with restrictive permissions and no symlink following."""
if not isinstance(encoded, bytes) or not encoded or len(encoded) > _MAX_BUNDLE_BYTES:
raise RecoveryBundlePathError("recovery bundle output is unsafe")
_check_path_components(path)
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(path, flags, 0o600)
with os.fdopen(descriptor, "wb") as handle:
handle.write(encoded)
handle.flush()
os.fsync(handle.fileno())
except OSError as error:
raise RecoveryBundlePathError("recovery bundle output is unsafe") from error
try:
info = path.lstat()
if (
stat.S_ISLNK(info.st_mode)
or not stat.S_ISREG(info.st_mode)
or stat.S_IMODE(info.st_mode) != 0o600
):
path.unlink(missing_ok=True)
raise RecoveryBundlePathError("recovery bundle output is unsafe")
except OSError as error:
raise RecoveryBundlePathError("recovery bundle output is unsafe") from error
def read_bundle_file(path: Path) -> bytes:
"""Read a regular, non-symlink bundle with a bounded size."""
_check_path_components(path)
flags = os.O_RDONLY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(path, flags)
with os.fdopen(descriptor, "rb") as handle:
info = os.fstat(handle.fileno())
if (
not stat.S_ISREG(info.st_mode)
or info.st_size <= 0
or info.st_size > _MAX_BUNDLE_BYTES
):
raise RecoveryBundlePathError("recovery bundle input is unsafe")
return handle.read()
except RecoveryBundlePathError:
raise
except OSError as error:
raise RecoveryBundlePathError("recovery bundle input is unsafe") from error
def read_passphrase_fd(fd: int) -> bytes:
"""Read one newline-terminated passphrase from an inherited file descriptor."""
if not isinstance(fd, int) or fd < 0:
raise RecoveryBundleError("recovery passphrase is unavailable")
try:
value = os.read(fd, _MAX_PASSPHRASE_BYTES + 2)
except OSError as error:
raise RecoveryBundleError("recovery passphrase is unavailable") from error
if value.endswith(b"\r\n"):
value = value[:-2]
elif value.endswith(b"\n"):
value = value[:-1]
if not value or len(value) > _MAX_PASSPHRASE_BYTES:
raise RecoveryBundleError("recovery passphrase is unavailable")
return value
@@ -0,0 +1,102 @@
from __future__ import annotations
import os
import stat
from pathlib import Path
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from backup_tool.config import Settings
from backup_tool.ids import new_uuid7
class RepositoryKeyError(ValueError):
pass
def _directory(settings: Settings) -> Path:
directory = settings.data_dir / "repository-data-keys"
directory.mkdir(mode=0o700, exist_ok=True)
if (
directory.is_symlink()
or not directory.is_dir()
or stat.S_IMODE(directory.stat().st_mode) != 0o700
):
raise RepositoryKeyError("repository data key directory is unsafe")
return directory
def create_data_key(settings: Settings, repository_id: str) -> tuple[str, Path]:
key_id = str(new_uuid7())
directory = _directory(settings)
path = directory / f"{repository_id}.{key_id}.key"
try:
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "wb") as handle:
handle.write(os.urandom(32))
handle.flush()
os.fsync(handle.fileno())
except OSError as error:
raise RepositoryKeyError("repository data key cannot be created") from error
if stat.S_IMODE(path.stat().st_mode) != 0o600 or path.is_symlink():
path.unlink(missing_ok=True)
raise RepositoryKeyError("repository data key is unsafe")
return key_id, path
def install_data_key(settings: Settings, repository_id: str, key_id: str, key: bytes) -> Path:
"""Install recovered key material once; never replace an existing key file."""
if len(key) != 32 or "/" in repository_id or "/" in key_id:
raise RepositoryKeyError("repository data key is invalid")
path = _directory(settings) / f"{repository_id}.{key_id}.key"
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(path, flags, 0o600)
with os.fdopen(descriptor, "wb") as handle:
handle.write(key)
handle.flush()
os.fsync(handle.fileno())
if path.is_symlink() or stat.S_IMODE(path.stat().st_mode) != 0o600:
path.unlink(missing_ok=True)
raise RepositoryKeyError("repository data key is unsafe")
except OSError as error:
raise RepositoryKeyError("repository data key cannot be installed") from error
return path
def object_aad(repository_id: str, key_id: str, kind: str, identity: str) -> bytes:
if kind not in {"blob", "manifest"} or not all((repository_id, key_id, identity)):
raise RepositoryKeyError("encrypted object metadata is invalid")
return f"BTENC:1:{repository_id}:{key_id}:{kind}:{identity}".encode()
def encrypt_object(key: bytes, aad: bytes, plaintext: bytes) -> bytes:
if len(key) != 32:
raise RepositoryKeyError("repository data key is unavailable")
nonce = os.urandom(12)
return b"BTENC\x01" + nonce + AESGCM(key).encrypt(nonce, plaintext, aad)
def decrypt_object(key: bytes, aad: bytes, stored: bytes) -> bytes:
if len(key) != 32 or not stored.startswith(b"BTENC\x01") or len(stored) < 35:
raise RepositoryKeyError("encrypted object is invalid")
try:
return AESGCM(key).decrypt(stored[6:18], stored[18:], aad)
except InvalidTag as error:
raise RepositoryKeyError("encrypted object is invalid") from error
def load_data_key(settings: Settings, repository_id: str, key_id: str) -> bytes:
path = _directory(settings) / f"{repository_id}.{key_id}.key"
try:
if path.is_symlink() or stat.S_IMODE(path.stat().st_mode) != 0o600:
raise RepositoryKeyError("repository data key is unsafe")
key = path.read_bytes()
except OSError as error:
raise RepositoryKeyError("repository data key is unavailable") from error
if len(key) != 32:
raise RepositoryKeyError("repository data key is unavailable")
return key
+121
View File
@@ -0,0 +1,121 @@
"""Fail-closed, DNS-rebinding-resistant webhook egress validation."""
from __future__ import annotations
import asyncio
import ipaddress
import socket
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass
from urllib.parse import SplitResult, urlsplit
class SSRFError(ValueError):
"""The callback target is not safe for the notification egress boundary."""
Resolver = Callable[[str, int], Awaitable[Sequence[str]]]
_ALLOWED_PORTS = frozenset({80, 443, 8080, 8443})
def _is_global(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
"""ipaddress.is_global misses some policy-important mapped/special ranges."""
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None:
return _is_global(address.ipv4_mapped)
return bool(address.is_global) and not any(
(
address.is_loopback,
address.is_private,
address.is_link_local,
address.is_multicast,
address.is_unspecified,
address.is_reserved,
)
)
def validate_webhook_url(value: str) -> SplitResult:
try:
parsed = urlsplit(value)
port = parsed.port
except ValueError as error:
raise SSRFError("webhook URL is malformed") from error
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise SSRFError("webhook URL must be absolute HTTP(S)")
if parsed.username is not None or parsed.password is not None or parsed.fragment:
raise SSRFError("webhook URL credentials and fragments are forbidden")
if len(value) > 2048 or any(character.isspace() for character in value):
raise SSRFError("webhook URL is malformed")
if port is not None and port not in _ALLOWED_PORTS:
raise SSRFError("webhook URL port is not permitted")
try:
ipaddress.ip_address(parsed.hostname)
except ValueError:
pass
else:
# Callback literals are never accepted: names are resolved immediately
# before every request and connected addresses are pinned/rechecked.
raise SSRFError("literal IP webhook targets are forbidden")
return parsed
async def system_resolver(hostname: str, port: int) -> Sequence[str]:
records = await asyncio.get_running_loop().getaddrinfo(
hostname, port, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP
)
addresses: set[str] = set()
for record in records:
socket_address = record[4]
if socket_address and isinstance(socket_address[0], str):
addresses.add(socket_address[0])
return tuple(sorted(addresses))
async def resolve_public_addresses(
hostname: str, port: int, resolver: Resolver = system_resolver
) -> tuple[str, ...]:
try:
candidates = tuple(await resolver(hostname, port))
except (TimeoutError, OSError) as error:
raise SSRFError("webhook DNS resolution failed") from error
if not candidates:
raise SSRFError("webhook hostname has no addresses")
approved: list[str] = []
for candidate in candidates:
try:
address = ipaddress.ip_address(candidate)
except ValueError as error:
raise SSRFError("webhook resolver returned an invalid address") from error
if not _is_global(address):
# One unsafe answer poisons the hostname, including mixed public/private.
raise SSRFError("webhook hostname resolves to a non-public address")
approved.append(str(address))
return tuple(approved)
@dataclass(frozen=True)
class ResolvedWebhookTarget:
url: SplitResult
port: int
addresses: tuple[str, ...]
async def resolve_webhook_target(
value: str, resolver: Resolver = system_resolver
) -> ResolvedWebhookTarget:
parsed = validate_webhook_url(value)
port = parsed.port or (443 if parsed.scheme == "https" else 80)
addresses = await resolve_public_addresses(parsed.hostname or "", port, resolver)
return ResolvedWebhookTarget(url=parsed, port=port, addresses=addresses)
def verify_connected_peer(peername: object, approved: Sequence[str]) -> str:
if not isinstance(peername, tuple) or not peername or not isinstance(peername[0], str):
raise SSRFError("webhook peer address is unavailable")
try:
peer = str(ipaddress.ip_address(peername[0]))
except ValueError as error:
raise SSRFError("webhook peer address is invalid") from error
if peer not in approved:
raise SSRFError("webhook peer changed after DNS resolution")
return peer
File diff suppressed because it is too large Load Diff
+288
View File
@@ -0,0 +1,288 @@
"""Pinned-host-key, forced-SFTP-only source reader.
The adapter has no command-channel API. It authenticates with the caller's
already decrypted private key only after verifying the configured host key.
"""
from __future__ import annotations
import asyncio
import hmac
import io
import socket
import stat
from collections.abc import AsyncIterator, Callable, Iterator
from contextlib import suppress
from typing import Any
import paramiko # type: ignore[import-untyped]
from backup_tool.adapters import Entry, SourceError
from backup_tool.config import Settings
from backup_tool.ssh_source import SSHSourcePublicConfig
TransportFactory = Callable[[str, int, float], Any]
SFTPFactory = Callable[[Any], Any]
def _transport_for(hostname: str, port: int, timeout: float) -> Any:
connection = socket.create_connection((hostname, port), timeout=timeout)
connection.settimeout(timeout)
return paramiko.Transport(connection)
def _sftp_for(transport: Any) -> Any:
return paramiko.SFTPClient.from_transport(transport)
def _next_or_none(iterator: Iterator[Any]) -> Any | None:
try:
return next(iterator)
except StopIteration:
return None
def load_private_key(value: str) -> Any:
"""Load only unencrypted Ed25519, ECDSA, or sufficiently strong RSA keys."""
for key_type in (paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.RSAKey):
try:
key = key_type.from_private_key(io.StringIO(value), password=None)
except (paramiko.SSHException, ValueError):
continue
if isinstance(key, paramiko.RSAKey) and key.get_bits() < 3072:
raise SourceError(
"SSH private key algorithm is not permitted", reason_code="source_auth"
)
return key
raise SourceError("SSH private key algorithm is not permitted", reason_code="source_auth")
class SSHAdapter:
"""A stateful SFTP reader rooted at the forced-SFTP account chroot only."""
def __init__(
self,
config: SSHSourcePublicConfig,
private_key: str,
settings: Settings,
*,
transport_factory: TransportFactory = _transport_for,
sftp_factory: SFTPFactory = _sftp_for,
) -> None:
self.config = config
self._private_key = load_private_key(private_key)
self.settings = settings
self._transport_factory = transport_factory
self._sftp_factory = sftp_factory
self._transport: Any | None = None
self._sftp: Any | None = None
self._files: dict[str, tuple[int, int]] = {}
def validate_config(self) -> None:
if self.config.root != "/": # defensive: persisted JSON can bypass API validation
raise SourceError(
"SSH source root must be the forced-SFTP chroot", reason_code="source_invalid"
)
def _connect(self) -> None:
if self._sftp is not None:
return
self.validate_config()
transport = self._transport_factory(
self.config.hostname, self.config.port, self.settings.ssh_connect_timeout_seconds
)
self._transport = transport
try:
transport.start_client(timeout=self.settings.ssh_connect_timeout_seconds)
algorithm, encoded_key = self.config.host_key.split(" ", 1)
server_key = transport.get_remote_server_key()
if server_key.get_name() != algorithm or not hmac.compare_digest(
server_key.get_base64(), encoded_key
):
raise SourceError(
"SSH host key does not match configured pin", reason_code="source_trust"
)
# Authentication deliberately occurs only after the exact pin comparison.
transport.auth_publickey(self.config.username, self._private_key)
sftp = self._sftp_factory(transport)
channel = sftp.get_channel()
channel.settimeout(self.settings.ssh_operation_timeout_seconds)
self._sftp = sftp
except SourceError:
self._close_sync()
raise
except paramiko.AuthenticationException as error:
self._close_sync()
raise SourceError("SSH authentication failed", reason_code="source_auth") from error
except (TimeoutError, OSError) as error:
self._close_sync()
raise SourceError(
"SSH source is unavailable", reason_code="source_unavailable"
) from error
except (paramiko.SSHException, ValueError) as error:
self._close_sync()
raise SourceError(
"SSH source connection failed", reason_code="source_unavailable"
) from error
def _sftp_client(self) -> Any:
if self._sftp is None:
raise SourceError("SSH source is unavailable", reason_code="source_unavailable")
return self._sftp
def _read_client(self) -> Any:
if self._transport is None:
raise SourceError("SSH source is unavailable", reason_code="source_unavailable")
client = self._sftp_factory(self._transport)
client.get_channel().settimeout(self.settings.ssh_operation_timeout_seconds)
return client
@staticmethod
def _relative(parent: str, name: object) -> str:
if not isinstance(name, str) or not name or name in {".", ".."}:
raise SourceError("SSH source returned an invalid entry", reason_code="source_invalid")
if "/" in name or "\\" in name or "\x00" in name:
raise SourceError("SSH source returned an invalid entry", reason_code="source_invalid")
return name if not parent else f"{parent}/{name}"
@staticmethod
def _remote_path(relative: str) -> str:
if (
not relative
or relative.startswith("/")
or "\\" in relative
or ".." in relative.split("/")
):
raise SourceError("SSH source entry is invalid", reason_code="source_invalid")
return f"/{relative}"
@staticmethod
def _entry_from_attributes(path: str, attributes: Any) -> Entry:
mode = getattr(attributes, "st_mode", None)
if not isinstance(mode, int):
raise SourceError("SSH source entry metadata is invalid", reason_code="source_invalid")
if stat.S_ISLNK(mode):
raise SourceError("SSH source symlinks are not supported", reason_code="source_invalid")
size = getattr(attributes, "st_size", 0)
mtime = getattr(attributes, "st_mtime", 0)
if not isinstance(size, int) or size < 0 or not isinstance(mtime, int):
raise SourceError("SSH source entry metadata is invalid", reason_code="source_invalid")
if stat.S_ISDIR(mode):
return Entry(path, "directory", 0, stat.S_IMODE(mode), mtime * 1_000_000_000)
if stat.S_ISREG(mode):
return Entry(path, "file", size, stat.S_IMODE(mode), mtime * 1_000_000_000)
raise SourceError("SSH source contains an unsupported entry", reason_code="source_invalid")
async def probe(self) -> dict[str, int]:
count = 0
try:
async for entry in self.enumerate_entries():
if entry.kind == "file":
count += 1
return {"entry_count": count}
finally:
await self.close()
async def enumerate_entries(self) -> AsyncIterator[Entry]:
await asyncio.to_thread(self._connect)
pending = [""]
entry_count = 0
try:
while pending:
parent = pending.pop()
remote_parent = "/" if not parent else self._remote_path(parent)
try:
iterator = await asyncio.to_thread(
self._sftp_client().listdir_iter,
remote_parent,
read_aheads=self.settings.ssh_list_read_aheads,
)
while (
attributes := await asyncio.to_thread(_next_or_none, iterator)
) is not None:
path = self._relative(parent, getattr(attributes, "filename", None))
entry_count += 1
if entry_count > self.settings.ssh_max_entries:
raise SourceError(
"SSH source entry limit exceeded", reason_code="source_limit"
)
entry = self._entry_from_attributes(path, attributes)
if entry.kind == "directory":
if path.count("/") + 1 > self.settings.ssh_max_traversal_depth:
raise SourceError(
"SSH source traversal depth exceeded",
reason_code="source_limit",
)
pending.append(path)
else:
self._files[path] = (entry.size, entry.mtime_ns)
yield entry
except SourceError:
raise
except (TimeoutError, OSError, paramiko.SSHException) as error:
raise SourceError(
"SSH source enumeration failed", reason_code="source_unavailable"
) from error
except BaseException:
await self.close()
raise
async def open_content(self, path: str) -> AsyncIterator[bytes]:
expected = self._files.get(path)
if expected is None:
raise SourceError("SSH source entry was not enumerated", reason_code="source_invalid")
remote_path = self._remote_path(path)
handle: Any | None = None
reader: Any | None = None
try:
await asyncio.to_thread(self._connect)
reader = await asyncio.to_thread(self._read_client)
assert reader is not None
attributes = await asyncio.to_thread(reader.lstat, remote_path)
entry = self._entry_from_attributes(path, attributes)
if entry.kind != "file" or (entry.size, entry.mtime_ns) != expected:
raise SourceError(
"SSH source file changed during backup", reason_code="source_changed"
)
handle = await asyncio.to_thread(
reader.open,
remote_path,
"rb",
self.settings.ssh_read_chunk_bytes,
)
assert handle is not None
while chunk := await asyncio.to_thread(handle.read, self.settings.ssh_read_chunk_bytes):
if not isinstance(chunk, bytes) or len(chunk) > self.settings.ssh_read_chunk_bytes:
raise SourceError(
"SSH source returned an invalid read", reason_code="source_invalid"
)
yield chunk
after = await asyncio.to_thread(reader.lstat, remote_path)
verified = self._entry_from_attributes(path, after)
if verified.kind != "file" or (verified.size, verified.mtime_ns) != expected:
raise SourceError(
"SSH source file changed during backup", reason_code="source_changed"
)
except SourceError:
raise
except (TimeoutError, OSError, paramiko.SSHException) as error:
raise SourceError("SSH source read failed", reason_code="source_unavailable") from error
finally:
if handle is not None:
await asyncio.to_thread(handle.close)
if reader is not None and reader is not self._sftp:
await asyncio.to_thread(reader.close)
def _close_sync(self) -> None:
sftp, transport = self._sftp, self._transport
self._sftp = None
self._transport = None
if sftp is not None:
with suppress(OSError, paramiko.SSHException):
sftp.close()
if transport is not None:
with suppress(OSError, paramiko.SSHException):
transport.close()
async def close(self) -> None:
await asyncio.to_thread(self._close_sync)
+72
View File
@@ -0,0 +1,72 @@
"""Closed public configuration for the staged SSH source capability.
This module deliberately describes configuration only. It must not create a
network transport or load a private key; those capabilities are outside this
slice.
"""
from __future__ import annotations
import base64
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
SSH_PRIVATE_KEY_PURPOSE = "ssh_private_key"
_ALLOWED_HOST_KEY_ALGORITHMS = frozenset(
{
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"rsa-sha2-256",
"rsa-sha2-512",
}
)
def _has_control_or_space(value: str) -> bool:
return any(character.isspace() or ord(character) < 32 for character in value)
class SSHSourcePublicConfig(BaseModel):
"""The public, SFTP-chroot-only portion of an SSH source definition."""
model_config = ConfigDict(extra="forbid", strict=True)
hostname: str = Field(min_length=1, max_length=253)
port: int = Field(ge=1, le=65535)
username: str = Field(min_length=1, max_length=255)
host_key: str = Field(min_length=1, max_length=16384)
# The server-side forced-SFTP account's chroot is the only permitted root.
root: Literal["/"]
@field_validator("hostname")
@classmethod
def validate_hostname(cls, value: str) -> str:
if _has_control_or_space(value) or any(character in value for character in "/\\@?#"):
raise ValueError("SSH hostname is invalid")
return value
@field_validator("username")
@classmethod
def validate_username(cls, value: str) -> str:
if _has_control_or_space(value) or any(character in value for character in "/\\:@"):
raise ValueError("SSH username is invalid")
return value
@field_validator("host_key")
@classmethod
def validate_host_key(cls, value: str) -> str:
algorithm, separator, encoded_key = value.partition(" ")
if not separator or not algorithm or not encoded_key or " " in encoded_key:
raise ValueError("SSH host key must be an algorithm and base64 key")
if algorithm not in _ALLOWED_HOST_KEY_ALGORITHMS:
raise ValueError("SSH host key algorithm is not supported")
try:
decoded = base64.b64decode(encoded_key, validate=True)
except (ValueError, UnicodeEncodeError) as error:
raise ValueError("SSH host key is not valid base64") from error
if not decoded:
raise ValueError("SSH host key is empty")
return value
+43
View File
@@ -0,0 +1,43 @@
"""Dedicated HTTP runtime role."""
from __future__ import annotations
import stat
import uvicorn
from backup_tool.config import Settings
from backup_tool.observability.logging import configure_logging, log_event
def _prepare_socket(settings: Settings) -> str:
path = settings.web_socket_path
path.parent.mkdir(mode=0o750, parents=True, exist_ok=True)
try:
existing = path.lstat()
except FileNotFoundError:
return str(path)
if not stat.S_ISSOCK(existing.st_mode):
raise RuntimeError("web socket path is not a socket")
path.unlink()
return str(path)
def run_web(settings: Settings) -> int:
"""Run exactly one ASGI server without reload or embedded background roles."""
from backup_tool.api.app import create_app
configure_logging("web", settings.log_level)
server = uvicorn.Server(
uvicorn.Config(
create_app(settings),
uds=_prepare_socket(settings),
log_level=settings.log_level.lower(),
reload=False,
workers=1,
log_config=None,
)
)
server.run()
log_event("role_stopped", role="web")
return 0
+222 -7
View File
@@ -1,22 +1,25 @@
"""Single-node durable worker role.
The worker owns leases; backup publishing is deliberately supplied by later M6 work.
The worker owns leases and performs repository I/O outside the API process.
"""
from __future__ import annotations
import asyncio
import contextlib
import importlib
import signal
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import cast
from uuid import uuid4
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from backup_tool.config import Settings
from backup_tool.db.engine import create_engine
from backup_tool.db.models import Execution
from backup_tool.db.models import Backup, Execution, Job, Repository, Restore, Source
from backup_tool.execution import (
claim,
complete_cancellation,
@@ -25,22 +28,158 @@ from backup_tool.execution import (
recover_stale,
transition,
)
from backup_tool.faults import FaultInjector, NoFault
from backup_tool.gc import process_retention_gc
from backup_tool.notifications.dispatcher import dispatch_one, recover_notification_leases
from backup_tool.notifications.events import emit_event
from backup_tool.observability.logging import configure_logging, log_event
from backup_tool.repository import reconcile_key_rotations
from backup_tool.security.secrets import EnvelopeCipher
snapshot = importlib.import_module("backup_tool.snapshot")
SnapshotError = snapshot.SnapshotError
SnapshotIntegrityError = snapshot.SnapshotIntegrityError
class Worker:
def __init__(self, settings: Settings, *, owner: str | None = None) -> None:
def __init__(
self,
settings: Settings,
*,
owner: str | None = None,
fault_injector: FaultInjector | None = None,
) -> None:
self.settings = settings
self.owner = owner or f"worker-{uuid4()}"
self.fault_injector = fault_injector or NoFault()
self._stopping = asyncio.Event()
self.engine = create_engine(settings)
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
self.cipher = EnvelopeCipher.from_file(settings.master_key_file)
self._execution_turns = 0
self._next_maintenance_at: datetime | None = None
async def startup(self) -> int:
async with self.sessions() as db:
return await recover_stale(db)
rotations = await reconcile_key_rotations(self.settings, db)
publications = cast(int, await snapshot.reconcile_publications(self.settings, db))
restored = cast(int, await snapshot.reconcile_restores(self.settings, db))
recovered_executions = await recover_stale(db)
recovered_deliveries = await recover_notification_leases(db)
maintenance = await process_retention_gc(db)
self._next_maintenance_at = datetime.now(UTC) + timedelta(seconds=60)
return (
rotations
+ publications
+ restored
+ recovered_executions
+ recovered_deliveries
+ maintenance.tombstoned
)
async def _run_restore(self, db: AsyncSession) -> bool:
restore_id = await db.scalar(
select(Restore.id)
.where(Restore.state == "queued")
.order_by(Restore.created_at)
.limit(1)
)
if restore_id is None:
return False
result = await db.execute(
update(Restore)
.where(Restore.id == restore_id, Restore.state == "queued")
.values(state="running")
)
if getattr(result, "rowcount", 0) != 1:
await db.rollback()
return False
await db.commit()
restore = await db.get(Restore, restore_id)
if restore is None:
return False
try:
backup = await db.get(Backup, restore.backup_id)
if backup is None or backup.integrity != "verified" or backup.tombstoned_at is not None:
raise SnapshotError("backup is unavailable")
execution = await db.get(Execution, backup.execution_id)
if execution is None:
raise SnapshotError("backup execution is unavailable")
job = await db.get(Job, execution.job_id)
if job is None:
raise SnapshotError("backup job is unavailable")
repository = await db.get(Repository, job.repository_id)
if repository is None:
raise SnapshotError("backup repository is unavailable")
restore.result = await snapshot.restore_full_snapshot(
self.settings, db, restore, backup, repository
)
restore.state = "committed"
await emit_event(
db,
"restore.committed",
correlation_id=restore.id,
resource={"restore_id": restore.id, "backup_id": restore.backup_id},
payload={"dry_run": restore.dry_run, "outcome": "committed"},
deduplication_key=f"restore:{restore.id}:committed",
)
await db.commit()
except SnapshotIntegrityError:
await db.rollback()
failed = await db.get(Restore, restore_id)
if failed is not None:
corrupted_backup = await db.get(Backup, failed.backup_id)
if corrupted_backup is not None:
corrupted_backup.integrity = "corrupt"
failed.state = "failed"
failed.result = {"reason": "restore_failed"}
await emit_event(
db,
"restore.failed",
correlation_id=failed.id,
resource={"restore_id": failed.id, "backup_id": failed.backup_id},
payload={"reason_code": "restore_failed"},
deduplication_key=f"restore:{failed.id}:failed",
)
await db.commit()
except SnapshotError:
await db.rollback()
failed = await db.get(Restore, restore_id)
if failed is not None:
failed.state = "failed"
failed.result = {"reason": "restore_failed"}
await emit_event(
db,
"restore.failed",
correlation_id=failed.id,
resource={"restore_id": failed.id, "backup_id": failed.backup_id},
payload={"reason_code": "restore_failed"},
deduplication_key=f"restore:{failed.id}:failed",
)
await db.commit()
return True
async def _run_maintenance(self, db: AsyncSession) -> None:
now = datetime.now(UTC)
if self._next_maintenance_at is None or now >= self._next_maintenance_at:
await process_retention_gc(db, now)
self._next_maintenance_at = now + timedelta(seconds=60)
async def run_once(self) -> bool:
if self._stopping.is_set():
return False
async with self.sessions() as db:
# Retention/GC is a worker responsibility, but is rate-limited so it
# cannot turn a sustained backup queue into a metadata polling loop.
await self._run_maintenance(db)
# Never let an always-nonempty execution queue starve due notifications.
if self._execution_turns >= 1 and await dispatch_one(
db, self.settings, self.cipher, self.owner
):
self._execution_turns = 0
return True
if self._stopping.is_set():
return False
execution_id = await db.scalar(
select(Execution.id)
.where(Execution.state == "queued")
@@ -48,10 +187,13 @@ class Worker:
.limit(1)
)
if execution_id is None:
return False
if await self._run_restore(db):
return True
return await dispatch_one(db, self.settings, self.cipher, self.owner)
execution = await claim(db, execution_id, self.owner)
if execution is None:
return False
self._execution_turns += 1
# Reload after the claim: a control request can race the lease acquisition.
await db.refresh(execution)
if execution.state == "cancelling":
@@ -73,6 +215,76 @@ class Worker:
await record_event(db, execution)
await db.commit()
await heartbeat(db, execution.id, self.owner)
failure_detail: str | None = None
failure_reason = "transient_io"
try:
job = await db.get(Job, execution.job_id)
if job is None:
raise SnapshotError("execution job is unavailable")
source = await db.get(Source, job.source_id)
repository = await db.get(Repository, job.repository_id)
if source is None or repository is None:
raise SnapshotError("execution source or repository is unavailable")
backup = await snapshot.publish_full_snapshot(
self.settings,
db,
execution,
job,
source,
repository,
self.fault_injector,
cipher=self.cipher,
)
await db.flush()
await emit_event(
db,
"backup.committed",
correlation_id=execution.id,
resource={
"execution_id": execution.id,
"job_id": job.id,
"repository_id": repository.id,
"backup_id": backup.id,
},
payload={"integrity": backup.integrity, "effective_mode": job.requested_mode},
deduplication_key=f"backup:{backup.id}:committed",
)
await emit_event(
db,
"backup.verification_succeeded",
correlation_id=execution.id,
resource={"execution_id": execution.id, "backup_id": backup.id},
payload={"integrity": backup.integrity},
deduplication_key=f"backup:{backup.id}:verified",
)
execution.state = transition("running", "verifying")
await record_event(db, execution)
self.fault_injector.hit("metadata.before_commit")
await db.commit()
self.fault_injector.hit("metadata.after_commit")
execution.state = transition("verifying", "committed")
execution.completed_at = datetime.now(UTC)
execution.lease_owner = None
execution.lease_expires_at = None
await record_event(db, execution)
await db.commit()
snapshot.finalize_publication(Path(repository.root), execution.id)
except SnapshotError as error:
await db.rollback()
failure_detail = str(error)
failure_reason = error.reason_code
if failure_detail is not None:
failed = await db.get(Execution, execution_id)
if failed is None or failed.state not in {"preparing", "running", "verifying"}:
return True
failed.state = "failed"
failed.reason_code = failure_reason
failed.operator_message = failure_detail
failed.completed_at = datetime.now(UTC)
failed.lease_owner = None
failed.lease_expires_at = None
await record_event(db, failed)
await db.commit()
return True
async def run(self) -> None:
@@ -84,10 +296,12 @@ class Worker:
await self.engine.dispose()
def stop(self) -> None:
log_event("role_stopping", role="worker")
self._stopping.set()
def run_worker(settings: Settings) -> int:
configure_logging("worker", settings.log_level)
worker = Worker(settings)
loop = asyncio.new_event_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
@@ -97,4 +311,5 @@ def run_worker(settings: Settings) -> int:
loop.run_until_complete(worker.run())
finally:
loop.close()
log_event("role_stopped", role="worker")
return 0