Skip to content

Admin endpoints to list, delete and report status for database backups #349

Description

@DutchJaFO

Background

Quotinator writes a safety backup before every migration, seed and Reset, into {dataDir}/backups/. Nothing can see them. There is no endpoint to list what exists, and no way to remove one — the only route is filesystem access to the container's volume, which a Home Assistant add-on user does not meaningfully have.

That gap has a concrete consequence rather than being merely untidy. The backups folder carries a hard size budget (MaxBackupStorageGb, default 1 GB). When it fills, backups stop being possible — and since #348 landed, that is a refusal: a startup degrades, or a Reset declines to run, because the folder is full. The remedy is to remove old backups, and there is currently no in-app way to do it.

There is a second gap of the same kind. A backup today only ever happens as a side effect of a migration, a seed or a Reset — an operator who wants a restore point before doing something has no way to ask for one, and no way to get a copy of one off the container.

Endpoints are preferred over telling an operator to delete or copy files by hand, for the reason stated in that issue's direction: an endpoint carries authorization and writes an audit trail, which is exactly what is wanted when diagnosing a problem that arises from the action later.

What needs to be done

  1. GET /api/v1/admin/backups — list the backup files that exist, each with the facts needed to decide which to remove: file name, size, and when it was taken. Paginated per CLAUDE.md's standard pagination contract, with the full eight-case test matrix that contract requires of every new paginated GET.

  2. DELETE /api/v1/admin/backups/{name} — remove one backup, identified by the file name the list returns.

  3. GET /api/v1/admin/backups/status — the one question an operator actually has, answered before they act rather than discovered when a Reset declines: can a backup be made right now, and are we within quota? (developer decision, 2026-08-27).

    Two halves, deliberately in one endpoint rather than two:

    It also reports real free disk space alongside the quota figures. These are independent constraints — the quota is self-imposed, free space is physical — and the backup path checks both, so showing only one would answer half the question and mislead exactly when the other is the binding one.

    It reads no database content and touches the filesystem only to the extent of the pre-flight's own zero-byte writability probe, which is what makes it safe to call while degraded (corrected 2026-08-29 — this was previously described as read-only, which the probe write makes untrue; a check that writes nothing proves nothing, per Reset returns an unhandled 500 when no backup can be taken, and the five backup failure causes are indistinguishable #348's own finding).

  4. GET /api/v1/admin/backups/{name}/content — download one backup, identified by the name the list returns, so a restore point can survive the container it was taken in. Streamed rather than buffered, with a Content-Disposition attachment name matching the stored file.

    Note the interaction with the admin policy: it is concurrency-1, so a download holds the only admin permit until it completes. At the expected size (~8 MB, per DatabaseOptions' own sizing note) that is immaterial; over a slow link it locks out every other admin route for the duration. Accepted rather than worked around, and recorded here so it is a known trade-off rather than a surprise.

  5. POST /api/v1/admin/backups/create — take a backup of the live database on demand (developer decision, 2026-08-29). Today a backup is only ever a side effect of something else; this makes it an action an operator can invoke when they want a restore point, and it is what Restore a stored backup, refusing one taken ahead of this build #352's restore endpoint deliberately does not do on their behalf.

    It refuses in Reset returns an unhandled 500 when no backup can be taken, and the five backup failure causes are indistinguishable #348's vocabulary — the obstacle plus its remedies — when a backup cannot be taken, exactly as a refused Reset does, rather than returning a success that produced no file. The response names the file it created, so the caller can then download it via requirement 4. An explicit action route rather than POST /backups, which would otherwise have to mean both this and Upload a backup file #353's upload.

  6. All five are admin-group endpoints, behind the admin API key and the admin concurrency-1 rate-limit policy, like every other /api/v1/admin/** route.

  7. All five must answer while the database is degraded, which is the state they exist for: an operator whose startup refused for want of a backup needs to see the quota, remove something, take a fresh one, and retry. They must therefore reach their handlers rather than being answered by the health gate — the property Startup crashes instead of degrading when the data directory is read-only and a migration is pending #326's Startup_DataDirectoryNotWritable_OpenApiRemainsReachableForRecovery already asserts for the admin surface, tested here for these routes specifically rather than assumed to follow.

  8. A deletion and a creation each write an audit entry. This is the reason for preferring an endpoint over manual file management, so it is a requirement rather than a nicety: a later "why is there no backup from that date" question must be answerable. AuditOperation.Backup already exists for the creation case and has never had a producer; the deletion needs a new member.

  9. No {name} route can escape the backups folder. The parameter is a file name, never a path — anything containing a separator or traversal segment is rejected before it reaches the filesystem, and the resolved path is verified to sit inside BackupsPath. One guard, shared by delete and download, not two implementations of the same check.

  10. A {name} that does not exist is a 404, for both delete and download, not a silent success and not an empty file — so a caller can tell "removed" from "was never there".

  11. docs/api-endpoints.md and the endpoints' [Description] attributes are updated in the same commit, per CLAUDE.md's "Keeping API documentation in sync".

  12. These endpoints form their own OpenAPI category, Backup (developer decision, 2026-08-27) — a new ApiTags.Backup constant, not the ApiTags.Admin that every /admin/** route carries today. A deliberate departure from that group-level default, stated here so the next reader does not "correct" it back: backup management is a distinct operator task, and burying these endpoints among Admin's reseed/reset/audit routes hides them from exactly the operator who has come looking because a backup could not be taken.

    That means their own MapGroup carrying .WithTags(ApiTags.Backup), while still chaining the admin API-key requirement and .RequireRateLimiting(RateLimitPolicies.Admin) — the category is a documentation grouping, never a relaxation of who may call them.

  13. The tag is declared with a description in the same commit, per ADR 020: .WithTags(...) puts an endpoint in a group but gives it neither description nor position, which come from the hand-maintained document.Tags array in Program.cs's AddDocumentTransformer. A tag used but not declared still renders — bare, undescribed, and after the declared ones — which is exactly how ApiTags.Notifications survived two releases before Restructure the T2 suite into docs/automated-testing/, one document per test #339 found it. OpenApiSpecEndpointTests.EveryTagAnEndpointUses_IsDeclaredWithADescription enforces it, and an empty description fails the same rule.

  14. Endpoint naming follows the convention: GetAllBackups / "List backups" for the list, and a PascalCase verb+object with an imperative summary for the actions — DeleteBackup, CreateBackup / "Create a backup" — per CLAUDE.md's endpoint naming convention, with each WithName value held in a private const string referenced by both the registration and its logging tag.

    Two endpoints are none of the three standard shapes, and each notes its deviation at the call site as the convention requires rather than being forced into wording that would misdescribe it. The status endpoint is not a list, not a fetch by id, and not an action that changes anything: GetBackupStatus / "Backup status". The download is a fetch, but by name rather than by id: GetBackupContent / "Backup content by name".

Expected tests

Test class Test method Starts
AdminBackupEndpointsTests GetBackups_ReturnsEachBackupWithItsNameSizeAndTimestamp ❌
AdminBackupEndpointsTests GetBackups_NoBackupsExist_ReturnsAnEmptyPageNotA404 ❌
AdminBackupEndpointsTests GetBackups_WithoutApiKey_Returns401 ❌
AdminBackupEndpointsTests DeleteBackup_RemovesOnlyTheNamedFile ❌
AdminBackupEndpointsTests DeleteBackup_WritesAnAuditEntry ❌
AdminBackupEndpointsTests DeleteBackup_UnknownName_Returns404 ❌
AdminBackupEndpointsTests DeleteBackup_WithoutApiKey_Returns401 ❌
AdminBackupEndpointsTests DeleteBackup_PathTraversalAttempt_IsRejectedAndDeletesNothing ❌
AdminBackupEndpointsTests DeleteBackup_AbsolutePathAttempt_IsRejectedAndDeletesNothing ❌
AdminBackupEndpointsTests AllRoutes_RemainReachableWhileDegraded ❌
AdminBackupDownloadEndpointTests Download_ReturnsTheFilesBytes_ByteForByte ❌
AdminBackupDownloadEndpointTests Download_SetsAnAttachmentNameMatchingTheStoredFile ❌
AdminBackupDownloadEndpointTests Download_UnknownName_Returns404 ❌
AdminBackupDownloadEndpointTests Download_PathTraversalAttempt_IsRejectedAndServesNothing ❌
AdminBackupDownloadEndpointTests Download_WithoutApiKey_Returns401 ❌
AdminBackupCreateEndpointTests Create_WritesABackupFile_AndNamesItInTheResponse ❌
AdminBackupCreateEndpointTests Create_TheCreatedFileAppearsInTheList_AndCanBeDownloaded ❌
AdminBackupCreateEndpointTests Create_WhenNoBackupCanBeTaken_RefusesWithTheObstacleAndItsRemedies ❌
AdminBackupCreateEndpointTests Create_WritesAnAuditEntry ❌
AdminBackupCreateEndpointTests Create_WithoutApiKey_Returns401 ❌
AdminBackupStatusEndpointTests GetStatus_WhenABackupIsPossible_SaysSo ❌
AdminBackupStatusEndpointTests GetStatus_WhenABackupIsNotPossible_NamesTheObstacle ❌
AdminBackupStatusEndpointTests GetStatus_ReportsUsedQuotaCeilingAndPercentage ❌
AdminBackupStatusEndpointTests GetStatus_ReportsWhetherTheReserveAboveTheQuotaIsInUse ❌
AdminBackupStatusEndpointTests GetStatus_ReportsRealFreeDiskSpaceSeparatelyFromTheQuota ❌
AdminBackupStatusEndpointTests GetStatus_NoBackupsExist_ReportsZeroUsedNotAnError ❌
AdminBackupStatusEndpointTests GetStatus_ReadsNoDatabaseContent_AndAnswersWhileDegraded ❌
AdminBackupStatusEndpointTests GetStatus_AgreesWithWhatACreateAttemptActuallyDoes ❌
AdminBackupStatusEndpointTests GetStatus_WithoutApiKey_Returns401 ❌
AdminBackupEndpointsPaginationTests Page_Zero_Returns422 ❌
AdminBackupEndpointsPaginationTests Page_Malformed_Returns422 ❌
AdminBackupEndpointsPaginationTests PageSize_Malformed_Returns422 ❌
AdminBackupEndpointsPaginationTests PageSize_Negative_Returns422 ❌
AdminBackupEndpointsPaginationTests PageSize_AboveMax_Returns422_NeverClamped ❌
AdminBackupEndpointsPaginationTests PageSize_Zero_ReturnsEveryRow_AndReportsTheActualCount ❌
AdminBackupEndpointsPaginationTests PageSize_Omitted_DefaultsToTwenty ❌
AdminBackupEndpointsPaginationTests Page_BeyondLastPage_Returns422_DistinctFromPageZero ❌
OpenApiSpecEndpointTests EveryTagAnEndpointUses_IsDeclaredWithADescription ✅ (must stay green)
OpenApiSpecEndpointTests BackupRoutes_AreTaggedBackup_NotAdmin ❌

DeleteBackup_PathTraversalAttempt_IsRejectedAndDeletesNothing asserts both halves deliberately: a rejected status alone would pass even if the file had already been removed. Download_PathTraversalAttempt_IsRejectedAndServesNothing does the same for the read side.

GetStatus_AgreesWithWhatACreateAttemptActuallyDoes is the check-and-attempt-agree property #348 found was worth its own test: a status endpoint that says a backup is possible when the create endpoint then refuses is worse than no status endpoint.

Definition of done

  • All expected tests listed above start red before implementation
  • All requirements implemented
  • All expected tests pass (green)
  • No regression in related tests
  • Findings summarised in a closing comment

Scope boundary

Restoring a backup is #352, and uploading one from outside the container is #353 — both filed 2026-08-29, both independent of this issue in either direction.

This issue is the file-management half: everything here either reads the backups folder or adds to it, and nothing here writes to the live database. That is what keeps it separable from #352, which is the only endpoint in the group that replaces the database, and it is why the create endpoint here is not folded into #352 — a restore taking its own backup would bolt a second data-retention decision onto an endpoint with one job.

Activity

  1. added this to the Notification system milestone on Aug 27, 2026
  2. added 14 commits that reference this issue on Aug 27, 2026
  3. DutchJaFO commented on Aug 29, 2026

    @DutchJaFO
    OwnerAuthor

    Two items were split out of this issue rather than built here, and one was added to it.

    Deferred

    Added during implementation

    The scope grew from three endpoints to five (2026-08-29): GET /{name}/content to download a backup,
    and POST /create to take one on demand. The issue body was updated in place to match. Create exists
    because #352's restore deliberately does not take a backup of its own — the operator takes one here,
    explicitly, if they want one.

    Not audited, deliberately

    A BackupDownloaded audit operation was added and then removed. docs/logging.md states that read
    operations are not in the audit trail, and a download leaves the backup in place — it copies rather than
    removes (developer decision, 2026-08-29). Reads are logged at Debug; create and delete at Information.

  4. added a commit that references this issue on Aug 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions