diff --git a/Makefile b/Makefile index d748c40..a7702d1 100644 --- a/Makefile +++ b/Makefile @@ -16,8 +16,8 @@ test-fast: $(PYTHON) -m pytest tests/unit tests/contract -q lint: - $(PYTHON) -m ruff check --config backend/pyproject.toml backend/src tests tools - $(PYTHON) -m ruff format --check --config backend/pyproject.toml backend/src tests tools + $(PYTHON) -m ruff check --config backend/pyproject.toml backend/src backend/alembic tests tools + $(PYTHON) -m ruff format --check --config backend/pyproject.toml backend/src backend/alembic tests tools typecheck: $(PYTHON) -m mypy --config-file backend/pyproject.toml diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..e5846ba --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,39 @@ +[alembic] +script_location = %(here)s/alembic +prepend_sys_path = %(here)s/src +path_separator = os +sqlalchemy.url = sqlite+aiosqlite:////var/lib/backup-tool/metadata.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..eff622e --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import importlib +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import create_engine, pool +from sqlalchemy.engine import URL, make_url + +Base = importlib.import_module("backup_tool.db.models").Base + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) +target_metadata = Base.metadata + + +def configured_url() -> str: + raw_url = os.environ.get("BACKUP_TOOL_DATABASE_URL") or config.get_main_option("sqlalchemy.url") + if raw_url is None: + raise RuntimeError("sqlalchemy.url is required") + return raw_url + + +def synchronous_url(raw_url: str) -> URL: + url = make_url(raw_url) + if url.drivername != "sqlite+aiosqlite": + raise RuntimeError("v2 migrations require sqlite+aiosqlite") + return url.set(drivername="sqlite") + + +def run_migrations_offline() -> None: + url = synchronous_url(configured_url()) + context.configure( + url=url.render_as_string(hide_password=False), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + engine = create_engine( + synchronous_url(configured_url()), + poolclass=pool.NullPool, + ) + with engine.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + engine.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..553d678 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/0001_v2_baseline.py b/backend/alembic/versions/0001_v2_baseline.py new file mode 100644 index 0000000..6f4afd0 --- /dev/null +++ b/backend/alembic/versions/0001_v2_baseline.py @@ -0,0 +1,548 @@ +"""v2 baseline + +Revision ID: 0001_v2_baseline +Revises: +Create Date: 2026-07-27 19:15:52.319338 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0001_v2_baseline" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "repositories", + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("root", sa.Text(), nullable=False), + sa.Column("format_version", sa.Integer(), nullable=False), + sa.Column("compression", sa.String(length=32), nullable=False), + sa.Column("encryption", sa.String(length=32), nullable=False), + sa.Column("state", sa.String(length=32), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint( + "state IN ('active','archived','unavailable')", name=op.f("ck_repositories_state") + ), + sa.CheckConstraint( + "format_version > 0", name=op.f("ck_repositories_format_version_positive") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_repositories")), + sa.UniqueConstraint("name", name=op.f("uq_repositories_name")), + sa.UniqueConstraint("root", name=op.f("uq_repositories_root")), + ) + op.create_table( + "secrets", + sa.Column("ciphertext", sa.LargeBinary(), nullable=False), + sa.Column("key_id", sa.String(length=255), nullable=False), + sa.Column("purpose", sa.String(length=64), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint("version > 0", name=op.f("ck_secrets_version_positive")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_secrets")), + ) + op.create_table( + "sources", + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("kind", sa.String(length=32), nullable=False), + sa.Column("public_config", sa.JSON(), nullable=False), + sa.Column("secret_refs", sa.JSON(), nullable=False), + sa.Column("state", sa.String(length=32), nullable=False), + sa.Column("last_probe", sa.JSON(), nullable=True), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint( + "kind IN ('local','sftp','postgresql','mysql')", name=op.f("ck_sources_kind") + ), + sa.CheckConstraint( + "state IN ('active','archived','unavailable')", name=op.f("ck_sources_state") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_sources")), + sa.UniqueConstraint("name", name=op.f("uq_sources_name")), + ) + op.create_table( + "users", + sa.Column("username", sa.String(length=255), nullable=False), + sa.Column("password_hash", sa.Text(), nullable=False), + sa.Column("state", sa.String(length=32), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint("state IN ('active','disabled')", name=op.f("ck_users_state")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_users")), + sa.UniqueConstraint("username", name=op.f("uq_users_username")), + ) + op.create_table( + "api_tokens", + sa.Column("owner_id", sa.String(length=36), nullable=False), + sa.Column("token_hash", sa.Text(), nullable=False), + sa.Column("scopes", sa.JSON(), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["owner_id"], + ["users.id"], + name=op.f("fk_api_tokens_owner_id_users"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_api_tokens")), + sa.UniqueConstraint("token_hash", name=op.f("uq_api_tokens_token_hash")), + ) + op.create_index("ix_api_tokens_owner_id", "api_tokens", ["owner_id"], unique=False) + op.create_table( + "audit_events", + sa.Column("actor_id", sa.String(length=36), nullable=True), + sa.Column("action", sa.String(length=255), nullable=False), + sa.Column("resource_type", sa.String(length=64), nullable=False), + sa.Column("resource_id", sa.String(length=36), nullable=True), + sa.Column("outcome", sa.String(length=32), nullable=False), + sa.Column("request_id", sa.String(length=36), nullable=False), + sa.Column("details", sa.JSON(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("id", sa.String(length=36), nullable=False), + sa.CheckConstraint( + "outcome IN ('success','failure','denied')", name=op.f("ck_audit_events_outcome") + ), + sa.ForeignKeyConstraint( + ["actor_id"], + ["users.id"], + name=op.f("fk_audit_events_actor_id_users"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_audit_events")), + ) + op.create_index("ix_audit_events_actor_id", "audit_events", ["actor_id"], unique=False) + op.create_index("ix_audit_events_created_at", "audit_events", ["created_at"], unique=False) + op.create_table( + "idempotency_records", + sa.Column("actor_id", sa.String(length=36), nullable=False), + sa.Column("key", sa.String(length=255), nullable=False), + sa.Column("operation", sa.String(length=255), nullable=False), + sa.Column("request_digest", sa.String(length=64), nullable=False), + sa.Column("response_resource_type", sa.String(length=64), nullable=False), + sa.Column("response_resource_id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("id", sa.String(length=36), nullable=False), + sa.ForeignKeyConstraint( + ["actor_id"], + ["users.id"], + name=op.f("fk_idempotency_records_actor_id_users"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_idempotency_records")), + sa.UniqueConstraint( + "actor_id", "key", "operation", name="uq_idempotency_actor_key_operation" + ), + ) + op.create_index( + "ix_idempotency_created_at", "idempotency_records", ["created_at"], unique=False + ) + op.create_table( + "jobs", + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("source_id", sa.String(length=36), nullable=False), + sa.Column("repository_id", sa.String(length=36), nullable=False), + sa.Column("requested_mode", sa.String(length=32), nullable=False), + sa.Column("exclusions", sa.JSON(), nullable=False), + sa.Column("retention", sa.JSON(), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column("allow_empty", sa.Boolean(), nullable=False), + sa.Column("state", sa.String(length=32), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint( + "requested_mode IN ('full','incremental')", name=op.f("ck_jobs_requested_mode") + ), + sa.CheckConstraint("state IN ('active','archived')", name=op.f("ck_jobs_state")), + sa.ForeignKeyConstraint( + ["repository_id"], + ["repositories.id"], + name=op.f("fk_jobs_repository_id_repositories"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["source_id"], + ["sources.id"], + name=op.f("fk_jobs_source_id_sources"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_jobs")), + sa.UniqueConstraint("name", name=op.f("uq_jobs_name")), + ) + op.create_index("ix_jobs_repository_id", "jobs", ["repository_id"], unique=False) + op.create_index("ix_jobs_source_id", "jobs", ["source_id"], unique=False) + op.create_table( + "notification_subscriptions", + sa.Column("channel", sa.String(length=32), nullable=False), + sa.Column("event_filters", sa.JSON(), nullable=False), + sa.Column("destination_config", sa.JSON(), nullable=False), + sa.Column("secret_id", sa.String(length=36), nullable=True), + sa.Column("state", sa.String(length=32), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint( + "channel IN ('webhook','email')", name=op.f("ck_notification_subscriptions_channel") + ), + sa.CheckConstraint( + "state IN ('active','disabled','archived')", + name=op.f("ck_notification_subscriptions_state"), + ), + sa.ForeignKeyConstraint( + ["secret_id"], + ["secrets.id"], + name=op.f("fk_notification_subscriptions_secret_id_secrets"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_notification_subscriptions")), + ) + op.create_table( + "notification_deliveries", + sa.Column("event_id", sa.String(length=36), nullable=False), + sa.Column("subscription_id", sa.String(length=36), nullable=False), + sa.Column("attempt", sa.Integer(), nullable=False), + sa.Column("state", sa.String(length=32), nullable=False), + sa.Column("response_class", sa.String(length=64), nullable=True), + sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint( + "state IN ('pending','delivered','retry','failed')", + name=op.f("ck_notification_deliveries_state"), + ), + sa.CheckConstraint("attempt > 0", name=op.f("ck_notification_deliveries_attempt_positive")), + sa.ForeignKeyConstraint( + ["subscription_id"], + ["notification_subscriptions.id"], + name=op.f("fk_notification_deliveries_subscription_id_notification_subscriptions"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_notification_deliveries")), + 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"], + unique=False, + ) + op.create_table( + "schedules", + sa.Column("job_id", sa.String(length=36), nullable=False), + sa.Column("cron", sa.String(length=255), nullable=False), + sa.Column("timezone", sa.String(length=255), nullable=False), + sa.Column("misfire_grace_seconds", sa.Integer(), nullable=False), + sa.Column("overlap_policy", sa.String(length=32), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column("next_nominal_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_enqueue_outcome", sa.String(length=64), nullable=True), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint( + "overlap_policy IN ('prohibit')", name=op.f("ck_schedules_overlap_policy") + ), + sa.CheckConstraint( + "misfire_grace_seconds >= 0", name=op.f("ck_schedules_misfire_nonnegative") + ), + sa.ForeignKeyConstraint( + ["job_id"], ["jobs.id"], name=op.f("fk_schedules_job_id_jobs"), ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_schedules")), + sa.UniqueConstraint("job_id", name=op.f("uq_schedules_job_id")), + ) + op.create_table( + "executions", + sa.Column("job_id", sa.String(length=36), nullable=False), + sa.Column("schedule_id", sa.String(length=36), nullable=True), + sa.Column("nominal_run_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("trigger", sa.String(length=32), nullable=False), + sa.Column("state", sa.String(length=32), nullable=False), + sa.Column("attempt", sa.Integer(), nullable=False), + sa.Column("lease_owner", sa.String(length=255), nullable=True), + sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("heartbeat_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("progress", sa.JSON(), nullable=False), + sa.Column("reason_code", sa.String(length=64), nullable=True), + sa.Column("operator_message", sa.Text(), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint( + "state IN ('queued','preparing','running','verifying','committed'," + "'cancelling','cancelled','failed')", + name=op.f("ck_executions_state"), + ), + sa.CheckConstraint( + "trigger IN ('manual','schedule','retry')", name=op.f("ck_executions_trigger") + ), + sa.CheckConstraint("attempt > 0", name=op.f("ck_executions_attempt_positive")), + sa.ForeignKeyConstraint( + ["job_id"], ["jobs.id"], name=op.f("fk_executions_job_id_jobs"), ondelete="RESTRICT" + ), + sa.ForeignKeyConstraint( + ["schedule_id"], + ["schedules.id"], + name=op.f("fk_executions_schedule_id_schedules"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_executions")), + sa.UniqueConstraint( + "schedule_id", "nominal_run_at", name="uq_execution_schedule_occurrence" + ), + ) + op.create_index("ix_executions_job_id", "executions", ["job_id"], unique=False) + op.create_index( + "ix_executions_state_created", "executions", ["state", "created_at"], unique=False + ) + op.create_index( + "uq_executions_active_job", + "executions", + ["job_id"], + unique=True, + sqlite_where=sa.text("state IN ('queued','preparing','running','verifying','cancelling')"), + ) + op.create_table( + "backups", + sa.Column("execution_id", sa.String(length=36), nullable=False), + sa.Column("parent_backup_id", sa.String(length=36), nullable=True), + sa.Column("manifest_id", sa.String(length=36), nullable=False), + sa.Column("manifest_digest", sa.String(length=64), nullable=False), + sa.Column("logical_bytes", sa.Integer(), nullable=False), + sa.Column("stored_bytes", sa.Integer(), nullable=False), + sa.Column("integrity", sa.String(length=32), nullable=False), + sa.Column("pinned", sa.Boolean(), nullable=False), + sa.Column("tombstoned_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("id", sa.String(length=36), nullable=False), + sa.CheckConstraint( + "integrity IN ('unverified','verified','degraded','corrupt')", + name=op.f("ck_backups_integrity"), + ), + sa.CheckConstraint("logical_bytes >= 0", name=op.f("ck_backups_logical_bytes_nonnegative")), + sa.CheckConstraint("stored_bytes >= 0", name=op.f("ck_backups_stored_bytes_nonnegative")), + sa.ForeignKeyConstraint( + ["execution_id"], + ["executions.id"], + name=op.f("fk_backups_execution_id_executions"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["parent_backup_id"], + ["backups.id"], + name=op.f("fk_backups_parent_backup_id_backups"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_backups")), + sa.UniqueConstraint("execution_id", name=op.f("uq_backups_execution_id")), + sa.UniqueConstraint("manifest_digest", name=op.f("uq_backups_manifest_digest")), + sa.UniqueConstraint("manifest_id", name=op.f("uq_backups_manifest_id")), + ) + op.create_index("ix_backups_created_at", "backups", ["created_at"], unique=False) + op.create_table( + "restores", + sa.Column("backup_id", sa.String(length=36), nullable=False), + sa.Column("destination", sa.Text(), nullable=False), + sa.Column("selection", sa.JSON(), nullable=False), + sa.Column("overwrite_policy", sa.String(length=32), nullable=False), + sa.Column("state", sa.String(length=32), nullable=False), + sa.Column("result", sa.JSON(), nullable=True), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.CheckConstraint( + "overwrite_policy IN ('fail','skip','replace')", + name=op.f("ck_restores_overwrite_policy"), + ), + sa.CheckConstraint( + "state IN ('queued','running','committed','cancelled','failed')", + name=op.f("ck_restores_state"), + ), + sa.ForeignKeyConstraint( + ["backup_id"], + ["backups.id"], + name=op.f("fk_restores_backup_id_backups"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_restores")), + ) + op.create_index("ix_restores_backup_id", "restores", ["backup_id"], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("ix_restores_backup_id", table_name="restores") + op.drop_table("restores") + op.drop_index("ix_backups_created_at", table_name="backups") + op.drop_table("backups") + op.drop_index( + "uq_executions_active_job", + table_name="executions", + sqlite_where=sa.text("state IN ('queued','preparing','running','verifying','cancelling')"), + ) + op.drop_index("ix_executions_state_created", table_name="executions") + op.drop_index("ix_executions_job_id", table_name="executions") + op.drop_table("executions") + op.drop_table("schedules") + op.drop_index("ix_notification_deliveries_state_next", table_name="notification_deliveries") + op.drop_table("notification_deliveries") + op.drop_table("notification_subscriptions") + op.drop_index("ix_jobs_source_id", table_name="jobs") + op.drop_index("ix_jobs_repository_id", table_name="jobs") + op.drop_table("jobs") + op.drop_index("ix_idempotency_created_at", table_name="idempotency_records") + op.drop_table("idempotency_records") + op.drop_index("ix_audit_events_created_at", table_name="audit_events") + op.drop_index("ix_audit_events_actor_id", table_name="audit_events") + op.drop_table("audit_events") + op.drop_index("ix_api_tokens_owner_id", table_name="api_tokens") + op.drop_table("api_tokens") + op.drop_table("users") + op.drop_table("sources") + op.drop_table("secrets") + op.drop_table("repositories") + # ### end Alembic commands ### diff --git a/backend/src/backup_tool/cli.py b/backend/src/backup_tool/cli.py index 343636b..d923937 100644 --- a/backend/src/backup_tool/cli.py +++ b/backend/src/backup_tool/cli.py @@ -1,4 +1,4 @@ -"""Minimal v2 command entry point; runtime roles arrive in M1.""" +"""Backup Tool v2 process-role command line.""" from __future__ import annotations @@ -7,10 +7,15 @@ from collections.abc import Sequence from backup_tool import __version__ +ROLES = ("web", "scheduler", "worker", "migrate", "admin") + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="backup-tool") parser.add_argument("--version", action="version", version=__version__) + subparsers = parser.add_subparsers(dest="role", required=True) + for role in ROLES: + subparsers.add_parser(role, help=f"run the {role} role") return parser diff --git a/backend/src/backup_tool/clock.py b/backend/src/backup_tool/clock.py new file mode 100644 index 0000000..2753b6f --- /dev/null +++ b/backend/src/backup_tool/clock.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Protocol + + +class Clock(Protocol): + def now(self) -> datetime: ... + + +class SystemClock: + def now(self) -> datetime: + return datetime.now(UTC) diff --git a/backend/src/backup_tool/config.py b/backend/src/backup_tool/config.py new file mode 100644 index 0000000..8418cc5 --- /dev/null +++ b/backend/src/backup_tool/config.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import os +import stat +from pathlib import Path +from typing import Literal, Self +from urllib.parse import urlsplit + +from pydantic import Field, field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from sqlalchemy.engine import make_url + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="BACKUP_TOOL_", + extra="forbid", + case_sensitive=False, + ) + + data_dir: Path = Path("/var/lib/backup-tool") + database_url: str = "sqlite+aiosqlite:////var/lib/backup-tool/metadata.db" + repository_roots: tuple[Path, ...] + local_source_roots: tuple[Path, ...] + restore_roots: tuple[Path, ...] + master_key_file: Path + public_base_url: str = "http://127.0.0.1:8000" + cors_origins: tuple[str, ...] = () + worker_concurrency: Literal[1] = 1 + 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) + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" + + @field_validator( + "data_dir", + "master_key_file", + mode="before", + ) + @classmethod + def validate_absolute_path(cls, value: object) -> Path: + path = Path(str(value)).expanduser() + if not path.is_absolute(): + raise ValueError("path must be absolute") + return path.resolve() + + @field_validator("repository_roots", "local_source_roots", "restore_roots", mode="before") + @classmethod + def validate_roots(cls, value: object) -> tuple[Path, ...]: + if isinstance(value, (str, Path)): + raw_roots = [value] + elif isinstance(value, (list, tuple, set)): + raw_roots = list(value) + else: + raise ValueError("allowlist roots must be a sequence of paths") + roots: list[Path] = [] + for raw_root in raw_roots: + root = Path(str(raw_root)).expanduser() + if not root.is_absolute(): + raise ValueError("allowlist roots must be absolute") + roots.append(root.resolve()) + if not roots: + raise ValueError("at least one allowlist root is required") + if len(set(roots)) != len(roots): + raise ValueError("allowlist roots must be unique") + return tuple(roots) + + @field_validator("database_url") + @classmethod + def validate_database_url(cls, value: str) -> str: + url = make_url(value) + if url.drivername != "sqlite+aiosqlite": + raise ValueError("v2 requires sqlite+aiosqlite") + if not url.database or not Path(url.database).is_absolute(): + raise ValueError("SQLite database path must be absolute") + return value + + @field_validator("public_base_url") + @classmethod + def validate_public_base_url(cls, value: str) -> str: + parsed = urlsplit(value) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise ValueError("public base URL must be an absolute HTTP(S) URL") + if parsed.query or parsed.fragment: + raise ValueError("public base URL must not contain query or fragment") + return value.rstrip("/") + + @model_validator(mode="after") + def validate_master_key(self) -> Self: + key = self.master_key_file + if not key.is_file(): + raise ValueError("master key file must exist and be a regular file") + key_stat = key.stat() + if hasattr(os, "getuid") and key_stat.st_uid != os.getuid(): + raise ValueError("master key file must be owned by the service user") + mode = stat.S_IMODE(key_stat.st_mode) + if mode != 0o600: + raise ValueError("master key file permissions must be 0600") + if key_stat.st_size < 32: + raise ValueError("master key file must contain at least 32 bytes") + return self + + @property + def database_path(self) -> Path: + database = make_url(self.database_url).database + if database is None: # pragma: no cover - guarded by validation + raise RuntimeError("database path is unavailable") + return Path(database).resolve() diff --git a/backend/src/backup_tool/db/__init__.py b/backend/src/backup_tool/db/__init__.py new file mode 100644 index 0000000..7435750 --- /dev/null +++ b/backend/src/backup_tool/db/__init__.py @@ -0,0 +1,4 @@ +from .engine import SchemaNotCurrentError, assert_schema_current, create_engine +from .models import Base + +__all__ = ["Base", "SchemaNotCurrentError", "assert_schema_current", "create_engine"] diff --git a/backend/src/backup_tool/db/engine.py b/backend/src/backup_tool/db/engine.py new file mode 100644 index 0000000..25402df --- /dev/null +++ b/backend/src/backup_tool/db/engine.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from alembic.config import Config +from alembic.migration import MigrationContext +from alembic.script import ScriptDirectory +from sqlalchemy import event +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + +from backup_tool.config import Settings + + +class SchemaNotCurrentError(RuntimeError): + pass + + +def create_engine(settings: Settings) -> AsyncEngine: + engine = create_async_engine(settings.database_url, pool_pre_ping=True) + + @event.listens_for(engine.sync_engine, "connect") + def configure_sqlite(dbapi_connection: Any, _connection_record: Any) -> None: + cursor = dbapi_connection.cursor() + try: + cursor.execute("PRAGMA foreign_keys=ON") + cursor.execute(f"PRAGMA busy_timeout={settings.sqlite_busy_timeout_ms}") + cursor.execute("PRAGMA journal_mode=WAL") + finally: + cursor.close() + + return engine + + +async def assert_schema_current(engine: AsyncEngine, alembic_config: Config) -> None: + expected = ScriptDirectory.from_config(alembic_config).get_current_head() + + def get_current_revision(connection: Connection) -> str | None: + return MigrationContext.configure(connection).get_current_revision() + + async with engine.connect() as connection: + current = await connection.run_sync(get_current_revision) + if current != expected: + raise SchemaNotCurrentError( + f"database schema is not current: expected {expected!r}, found {current!r}" + ) + + +SchemaCheck = Callable[[AsyncEngine, Config], Any] diff --git a/backend/src/backup_tool/db/models.py b/backend/src/backup_tool/db/models.py new file mode 100644 index 0000000..73ce006 --- /dev/null +++ b/backend/src/backup_tool/db/models.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import ( + JSON, + Boolean, + CheckConstraint, + DateTime, + ForeignKey, + Index, + Integer, + LargeBinary, + MetaData, + String, + Text, + UniqueConstraint, + func, + text, +) +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +NAMING_CONVENTION = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +class Base(DeclarativeBase): + metadata = MetaData(naming_convention=NAMING_CONVENTION) + + +class IdentityMixin: + id: Mapped[str] = mapped_column(String(36), primary_key=True) + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.current_timestamp() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + ) + + +class User(IdentityMixin, TimestampMixin, Base): + __tablename__ = "users" + username: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + password_hash: Mapped[str] = mapped_column(Text, nullable=False) + state: Mapped[str] = mapped_column(String(32), nullable=False, default="active") + __table_args__ = (CheckConstraint("state IN ('active','disabled')", name="state"),) + + +class ApiToken(IdentityMixin, TimestampMixin, Base): + __tablename__ = "api_tokens" + owner_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT")) + token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True) + scopes: Mapped[list[str]] = mapped_column(JSON, nullable=False) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + __table_args__ = (Index("ix_api_tokens_owner_id", "owner_id"),) + + +class Secret(IdentityMixin, TimestampMixin, Base): + __tablename__ = "secrets" + ciphertext: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + key_id: Mapped[str] = mapped_column(String(255), nullable=False) + purpose: Mapped[str] = mapped_column(String(64), nullable=False) + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + __table_args__ = (CheckConstraint("version > 0", name="version_positive"),) + + +class Repository(IdentityMixin, TimestampMixin, Base): + __tablename__ = "repositories" + name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + root: Mapped[str] = mapped_column(Text, nullable=False, unique=True) + 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) + state: Mapped[str] = mapped_column(String(32), nullable=False, default="active") + __table_args__ = ( + CheckConstraint("format_version > 0", name="format_version_positive"), + CheckConstraint("state IN ('active','archived','unavailable')", name="state"), + ) + + +class Source(IdentityMixin, TimestampMixin, Base): + __tablename__ = "sources" + name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + kind: Mapped[str] = mapped_column(String(32), nullable=False) + public_config: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + secret_refs: Mapped[list[str]] = mapped_column(JSON, nullable=False) + 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("state IN ('active','archived','unavailable')", name="state"), + ) + + +class Job(IdentityMixin, TimestampMixin, Base): + __tablename__ = "jobs" + name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + source_id: Mapped[str] = mapped_column(ForeignKey("sources.id", ondelete="RESTRICT")) + repository_id: Mapped[str] = mapped_column(ForeignKey("repositories.id", ondelete="RESTRICT")) + requested_mode: Mapped[str] = mapped_column(String(32), nullable=False, default="incremental") + exclusions: Mapped[list[str]] = mapped_column(JSON, nullable=False) + retention: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + allow_empty: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + state: Mapped[str] = mapped_column(String(32), nullable=False, default="active") + __table_args__ = ( + CheckConstraint("requested_mode IN ('full','incremental')", name="requested_mode"), + CheckConstraint("state IN ('active','archived')", name="state"), + Index("ix_jobs_source_id", "source_id"), + Index("ix_jobs_repository_id", "repository_id"), + ) + + +class Schedule(IdentityMixin, TimestampMixin, Base): + __tablename__ = "schedules" + job_id: Mapped[str] = mapped_column( + ForeignKey("jobs.id", ondelete="RESTRICT"), nullable=False, unique=True + ) + cron: Mapped[str] = mapped_column(String(255), nullable=False) + timezone: Mapped[str] = mapped_column(String(255), nullable=False) + misfire_grace_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=900) + overlap_policy: Mapped[str] = mapped_column(String(32), nullable=False, default="prohibit") + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + next_nominal_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_enqueue_outcome: Mapped[str | None] = mapped_column(String(64)) + __table_args__ = ( + CheckConstraint("misfire_grace_seconds >= 0", name="misfire_nonnegative"), + CheckConstraint("overlap_policy IN ('prohibit')", name="overlap_policy"), + ) + + +class Execution(IdentityMixin, TimestampMixin, Base): + __tablename__ = "executions" + job_id: Mapped[str] = mapped_column(ForeignKey("jobs.id", ondelete="RESTRICT")) + schedule_id: Mapped[str | None] = mapped_column(ForeignKey("schedules.id", ondelete="RESTRICT")) + nominal_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + trigger: Mapped[str] = mapped_column(String(32), nullable=False) + state: Mapped[str] = mapped_column(String(32), nullable=False, default="queued") + attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + lease_owner: Mapped[str | None] = mapped_column(String(255)) + lease_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + progress: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + reason_code: Mapped[str | None] = mapped_column(String(64)) + operator_message: Mapped[str | None] = mapped_column(Text) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + __table_args__ = ( + CheckConstraint("attempt > 0", name="attempt_positive"), + CheckConstraint( + "state IN ('queued','preparing','running','verifying','committed'," + "'cancelling','cancelled','failed')", + name="state", + ), + CheckConstraint("trigger IN ('manual','schedule','retry')", name="trigger"), + UniqueConstraint("schedule_id", "nominal_run_at", name="uq_execution_schedule_occurrence"), + Index("ix_executions_job_id", "job_id"), + Index("ix_executions_state_created", "state", "created_at"), + Index( + "uq_executions_active_job", + "job_id", + unique=True, + sqlite_where=text("state IN ('queued','preparing','running','verifying','cancelling')"), + ), + ) + + +class Backup(IdentityMixin, Base): + __tablename__ = "backups" + execution_id: Mapped[str] = mapped_column( + ForeignKey("executions.id", ondelete="RESTRICT"), nullable=False, unique=True + ) + parent_backup_id: Mapped[str | None] = mapped_column( + ForeignKey("backups.id", ondelete="RESTRICT") + ) + manifest_id: Mapped[str] = mapped_column(String(36), nullable=False, unique=True) + manifest_digest: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + 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") + pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + tombstoned_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.current_timestamp() + ) + __table_args__ = ( + CheckConstraint("logical_bytes >= 0", name="logical_bytes_nonnegative"), + CheckConstraint("stored_bytes >= 0", name="stored_bytes_nonnegative"), + CheckConstraint( + "integrity IN ('unverified','verified','degraded','corrupt')", name="integrity" + ), + Index("ix_backups_created_at", "created_at"), + ) + + +class Restore(IdentityMixin, TimestampMixin, Base): + __tablename__ = "restores" + 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) + 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) + __table_args__ = ( + CheckConstraint("overwrite_policy IN ('fail','skip','replace')", name="overwrite_policy"), + CheckConstraint( + "state IN ('queued','running','committed','cancelled','failed')", name="state" + ), + Index("ix_restores_backup_id", "backup_id"), + ) + + +class AuditEvent(IdentityMixin, Base): + __tablename__ = "audit_events" + actor_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT")) + action: Mapped[str] = mapped_column(String(255), nullable=False) + resource_type: Mapped[str] = mapped_column(String(64), nullable=False) + resource_id: Mapped[str | None] = mapped_column(String(36)) + outcome: Mapped[str] = mapped_column(String(32), nullable=False) + request_id: Mapped[str] = mapped_column(String(36), nullable=False) + details: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.current_timestamp() + ) + __table_args__ = ( + CheckConstraint("outcome IN ('success','failure','denied')", name="outcome"), + Index("ix_audit_events_created_at", "created_at"), + Index("ix_audit_events_actor_id", "actor_id"), + ) + + +class NotificationSubscription(IdentityMixin, TimestampMixin, Base): + __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) + secret_id: Mapped[str | None] = mapped_column(ForeignKey("secrets.id", ondelete="RESTRICT")) + state: Mapped[str] = mapped_column(String(32), nullable=False, default="active") + __table_args__ = ( + CheckConstraint("channel IN ('webhook','email')", name="channel"), + CheckConstraint("state IN ('active','disabled','archived')", name="state"), + ) + + +class NotificationDelivery(IdentityMixin, TimestampMixin, Base): + __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") + ) + attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + state: Mapped[str] = mapped_column(String(32), nullable=False) + response_class: Mapped[str | None] = mapped_column(String(64)) + next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + __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"), + ) + + +class IdempotencyRecord(IdentityMixin, Base): + __tablename__ = "idempotency_records" + actor_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT")) + key: Mapped[str] = mapped_column(String(255), nullable=False) + operation: Mapped[str] = mapped_column(String(255), nullable=False) + request_digest: Mapped[str] = mapped_column(String(64), nullable=False) + response_resource_type: Mapped[str] = mapped_column(String(64), nullable=False) + response_resource_id: Mapped[str] = mapped_column(String(36), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.current_timestamp() + ) + __table_args__ = ( + UniqueConstraint("actor_id", "key", "operation", name="uq_idempotency_actor_key_operation"), + Index("ix_idempotency_created_at", "created_at"), + ) diff --git a/backend/src/backup_tool/ids.py b/backend/src/backup_tool/ids.py new file mode 100644 index 0000000..08e626b --- /dev/null +++ b/backend/src/backup_tool/ids.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import secrets +import threading +import time +from uuid import UUID + +_lock = threading.Lock() +_last_millisecond = -1 +_last_random = 0 +_RANDOM_MASK = (1 << 74) - 1 + + +def new_uuid7() -> UUID: + """Return a process-monotonic RFC 9562 UUIDv7.""" + global _last_millisecond, _last_random + with _lock: + millisecond = time.time_ns() // 1_000_000 + if millisecond > _last_millisecond: + _last_millisecond = millisecond + _last_random = secrets.randbits(74) + else: + millisecond = _last_millisecond + _last_random = (_last_random + 1) & _RANDOM_MASK + if _last_random == 0: + _last_millisecond += 1 + millisecond = _last_millisecond + random_a = (_last_random >> 62) & 0xFFF + random_b = _last_random & ((1 << 62) - 1) + integer = (millisecond & ((1 << 48) - 1)) << 80 + integer |= 0x7 << 76 + integer |= random_a << 64 + integer |= 0b10 << 62 + integer |= random_b + return UUID(int=integer) diff --git a/docs/release/m1-evidence.md b/docs/release/m1-evidence.md new file mode 100644 index 0000000..7960979 --- /dev/null +++ b/docs/release/m1-evidence.md @@ -0,0 +1,43 @@ +# M1 Runtime and Metadata Evidence + +## RED + +Commit: `8f78484 test(v2): define runtime and metadata contracts` + +Command: + +```bash +.venv/bin/python -m pytest tests/unit/test_config.py tests/integration/test_migrations.py -q +``` + +Observed result: exit 2 during collection because `backup_tool.clock` did not exist. This proves the test-only commit preceded runtime implementation. + +## GREEN + +Migration gate executed against a disposable absolute SQLite database: + +```bash +cd backend +BACKUP_TOOL_DATABASE_URL=sqlite+aiosqlite:////absolute/path/.m1-gate.db ../.venv/bin/python -m alembic upgrade head +BACKUP_TOOL_DATABASE_URL=sqlite+aiosqlite:////absolute/path/.m1-gate.db ../.venv/bin/python -m alembic downgrade base +BACKUP_TOOL_DATABASE_URL=sqlite+aiosqlite:////absolute/path/.m1-gate.db ../.venv/bin/python -m alembic upgrade head +../.venv/bin/python -m pytest ../tests/unit/test_config.py ../tests/integration/test_migrations.py -q +cd .. +make check +``` + +Observed results: + +- Alembic upgrade, downgrade, and second upgrade each exited 0. +- Focused M1 suite: `13 passed`. +- Fast unit/contract suite: `28 passed`. +- Ruff check/format and strict mypy: passed. +- Frontend typecheck/build: passed. +- Disposable database removed after verification. + +## Hand Review + +- Baseline revision contains explicit `op.create_table`, constraints, foreign keys, and indexes for all 14 v2 entities; it does not call `create_all`. +- SQLite connections enable foreign keys, WAL, and a bounded busy timeout. +- Startup compares the database Alembic revision with the current script head and rejects unmigrated databases. +- Runtime roles are explicit CLI subcommands; no role starts another role in M1. diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index a9f556a..c7c0acb 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -51,7 +51,8 @@ def settings_for(tmp_path: Path) -> Any: def alembic_config(database_url: str) -> Config: - config = Config("backend/alembic.ini") + project_root = Path(__file__).resolve().parents[2] + config = Config(project_root / "backend" / "alembic.ini") config.set_main_option("sqlalchemy.url", database_url) return config