205 Commits

Author SHA1 Message Date
51142081c9 Merge pull request '(release) Prepare 1.2.0' (#74) from release-1.2 into master
Reviewed-on: #74
2026-05-28 22:19:23 +02:00
02616eebbc (release) Prepare 1.2.0
Bump the package version to 1.2.0 and document the operations-focused release
with updater, readonly access, notifications, bandwidth controls, live progress,
and more robust retention cleanup.

Make the CLI version test assert against the package version so future release
bumps do not require changing a hardcoded expected value.
2026-05-28 22:19:03 +02:00
a61e3d8302 Merge pull request '(feature) Add staff updater page' (#73) from issue-39-updater-ui into master
Reviewed-on: #73
2026-05-28 22:11:07 +02:00
0450f8bdb0 (feature) Add staff updater page
Add a Django updater view for checking configured Gitea releases, inspecting
the installed git checkout, fetching tags, pulling the current branch, and
running the configured native systemd update command.

Document the updater environment settings and keep the page staff-only so
readonly status users cannot trigger deployment actions.
2026-05-28 22:10:45 +02:00
b4833560b5 Merge pull request '(feature) Add read-only access level to control panel' (#72) from issue-40-access-levels into master
Reviewed-on: #72
2026-05-28 22:00:45 +02:00
81ee848f5f (feature) Add read-only access level to control panel
Introduce a central access policy that lets authenticated non-staff users view
backup status pages while keeping credentials, logs, configs, and mutating
actions staff-only.

Hide sensitive navigation and host controls for read-only users, expose only
the status API to authenticated viewers, and document the two access levels.
2026-05-28 22:00:16 +02:00
7f2bbe4d20 Merge pull request '(bugfix) Enable rsync progress output for live real runs' (#71) from issue-64-rsync-progress-live-runs into master
Reviewed-on: #71
2026-05-28 21:43:02 +02:00
29f455a153 (bugfix) Enable rsync progress output for live real runs
Default queued and management-command backups to verbose rsync output so live
run views show progress for long-running real backups, matching dry-run
visibility.

Add a quiet-rsync escape hatch for operators who intentionally want less noisy
real-run logs.
2026-05-28 21:42:40 +02:00
41ceab5a40 Merge pull request '(bugfix) Measure incomplete snapshot data from disk' (#70) from issue-69-data-overview-incomplete-sizes into master
Reviewed-on: #70
2026-05-28 21:34:22 +02:00
2ad119e214 (bugfix) Measure incomplete snapshot data from disk
Use filesystem usage for incomplete snapshots instead of trusting potentially
stale metadata, and expose unique non-hardlinked data totals for completed
snapshots.

Update dashboard and host storage summaries so incomplete data is visible and
complete snapshot totals distinguish allocated and unique data.
2026-05-28 21:33:26 +02:00
eb121453c8 Merge pull request '(feature) Add run completion notifications' (#68) from issue-50-run-notifications into master
Reviewed-on: #68
2026-05-28 21:20:59 +02:00
67ffd6101b (feature) Add run completion notifications
Add email and webhook notification targets with delivery tracking, and send
notifications when backup runs reach a terminal status.

Expose notification target management in the Django UI and keep delivery
failures recorded without failing the backup worker.
2026-05-28 21:20:38 +02:00
1f5c4e0756 Merge pull request '(feature) Require review before incomplete cleanup' (#67) from issue-47-reviewed-incomplete-cleanup into master
Reviewed-on: #67
2026-05-28 21:08:21 +02:00
b5e87abad2 (feature) Require review before incomplete cleanup
Require incomplete snapshots to be marked reviewed before the cleanup action
can delete them, and show review state in the retention plan UI.

Keep cleanup confirmation counts scoped to reviewed incomplete snapshots and
add coverage for blocked, reviewed, and deletion flows.
2026-05-28 21:07:59 +02:00
fc6df89370 Merge pull request '(bugfix) Make snapshot pruning robust to archived permissions' (#66) from issue-47-65-robust-prune-cleanup into master
Reviewed-on: #66
2026-05-28 20:59:23 +02:00
3893df4640 (bugfix) Make snapshot pruning robust to archived permissions
Repair user permissions inside snapshot trees before deleting them so
retention prune and incomplete cleanup can remove directories copied with
restrictive rsync archive modes.

Add path validation for scheduled/manual snapshot deletes and cover
non-traversable nested directories in retention tests.
2026-05-28 20:58:37 +02:00
f86c67aeee Merge pull request '## Summary' (#63) from bugfix/62-incomplete-backup-data-totals into master
Reviewed-on: #63
2026-05-23 01:36:18 +02:00
7dc4c1df84 ## Summary
- Fall back to filesystem measurement when snapshot storage metadata is missing.
- Prefer `data/` inside snapshot directories so incomplete snapshot metadata/log files are not counted as backup data.
- Add stats and dashboard rendering coverage for incomplete snapshots without recorded storage metadata.

## Tests
- .venv/bin/python manage.py test src.pobsync_backend.tests.test_stats_summary src.pobsync_backend.tests.test_views.ViewTests.test_dashboard_host_cards_measure_incomplete_data_without_snapshot_metadata --verbosity 2
- .venv/bin/python manage.py check
- git diff --check
- .venv/bin/python manage.py test src.pobsync_backend --verbosity 2

Closes #62
2026-05-23 01:35:38 +02:00
10e0293559 Merge pull request '(feature) Show backup data totals by snapshot kind' (#61) from issue-53-host-backup-data-totals into master
Reviewed-on: #61
2026-05-23 01:28:18 +02:00
9dd690bb3b (feature) Show backup data totals by snapshot kind
Aggregate snapshot storage metadata by snapshot kind so operators can see
scheduled, manual, incomplete, and total backup data.

Surface the totals per host and across all hosts on the dashboard, using
allocated snapshot size from recorded backup metadata without rescanning
backup storage.
2026-05-23 01:27:51 +02:00
8740b75841 Merge pull request '## Summary' (#60) from issue-48-49-hosts-page-controls into master
Reviewed-on: #60
2026-05-23 01:14:48 +02:00
ce1cb9d157 ## Summary
- Add a dedicated `/hosts/` page with host cards and enabled/disabled filtering.
- Link the dashboard Hosts metric and top navigation to the new page.
- Add host enable/disable plus schedule and scheduled-retention pause/resume actions.

## Tests
- `.venv/bin/python manage.py test src.pobsync_backend.tests.test_views.ViewTests.test_base_navigation_groups_primary_and_system_links src.pobsync_backend.tests.test_views.ViewTests.test_dashboard_renders_hosts_and_latest_runs src.pobsync_backend.tests.test_views.ViewTests.test_dashboard_hosts_live_returns_hosts_partial src.pobsync_backend.tests.test_views.ViewTests.test_hosts_list_renders_host_cards_and_controls src.pobsync_backend.tests.test_views.ViewTests.test_hosts_list_filters_by_enabled_state src.pobsync_backend.tests.test_views.ViewTests.test_update_host_state_toggles_host_schedule_and_retention --verbosity 2`
- `.venv/bin/python manage.py check`
- `.venv/bin/python manage.py test src.pobsync_backend --verbosity 2`

Closes #48
Closes #49
2026-05-23 01:13:32 +02:00
8e83fee7b5 Merge pull request '## Summary' (#59) from issue-55-writable-test-state into master
Reviewed-on: #59
2026-05-23 01:07:26 +02:00
a6d6468da8 ## Summary
- Override `POBSYNC_HOME` in the filesystem SSH credential test.
- Keep credential materialization tests self-contained and writable in local development.
- Leave production runtime defaults unchanged.

## Tests
- `.venv/bin/python manage.py test src.pobsync_backend.tests.test_django_config_source --verbosity 2`
- `.venv/bin/python manage.py test src.pobsync_backend --verbosity 2`

Closes #55
2026-05-23 01:06:22 +02:00
b87203c538 Merge pull request '## Summary' (#58) from issue-51-bandwidth-limit into master
Reviewed-on: #58
2026-05-23 01:02:28 +02:00
515330c436 ## Summary
- Add per-host rsync bandwidth limit overrides with inherit/unlimited semantics.
- Store the effective bwlimit in run metadata/results and show it in host/run detail views.
- Document recommended starting values for VPN and remote backups.

## Tests
- `.venv/bin/python manage.py makemigrations --check --dry-run`
- `.venv/bin/python manage.py test src.pobsync_backend.tests.test_django_config_source.DjangoConfigSourceTests.test_returns_effective_config_from_database src.pobsync_backend.tests.test_django_config_source.DjangoConfigSourceTests.test_host_can_disable_global_rsync_bandwidth_limit src.pobsync_backend.tests.test_configure_commands.ConfigureCommandsTests.test_configure_host_uses_global_retention_defaults src.pobsync_backend.tests.test_run_scheduled_config_source.RunScheduledConfigSourceTests.test_dry_run_applies_configured_bandwidth_limit src.pobsync_backend.tests.test_run_scheduled_config_source.RunScheduledConfigSourceTests.test_real_run_can_request_verbose_output_args --verbosity 2`
- `.venv/bin/python manage.py test src.pobsync_backend.tests.test_views.ViewTests.test_create_host_config_form_creates_host src.pobsync_backend.tests.test_views.ViewTests.test_host_detail_renders_effective_config_preview src.pobsync_backend.tests.test_views.ViewTests.test_run_detail_renders_result_payload src.pobsync_backend.tests.test_views.ViewTests.test_host_config_form_updates_host_config --verbosity 2`
- `.venv/bin/python manage.py check`

Closes #51
2026-05-23 00:59:55 +02:00
fdf401a0be Merge pull request '(refactor) Unify run progress panels' (#57) from issue-52-live-normal-runs into master
Reviewed-on: #57
2026-05-23 00:48:22 +02:00
3b77f2e5d0 (refactor) Unify run progress panels
Use a shared Run Progress presentation for dry-runs and normal backup
runs so live run feedback is consistent across run types.

Keep mode-specific metrics while aligning status, mode, log, and warning
layout.

Refs #52
2026-05-23 00:46:52 +02:00
df9ec5b04c Merge pull request 'issue-54-worker-rsync-state' (#56) from issue-54-worker-rsync-state into master
Reviewed-on: #56
2026-05-23 00:39:47 +02:00
5788f53854 (bugfix) Keep rsync runner callback optional
Only pass the process_started hook when live run state tracking is active,
so existing rsync call sites and tests without that hook remain compatible.

Refs #54
2026-05-23 00:31:24 +02:00
28da9c4096 (bugfix) Track rsync process state for running backups
Record rsync process pid and execution phase while normal backup runs are
active so the worker can reconcile stale running rows when rsync has
already disappeared.

Keep finalizing runs out of the missing-process path to avoid marking
slow post-rsync stats collection as a failed transfer.

Closes #54
2026-05-23 00:26:22 +02:00
6eb1b4add3 (bugfix) Reconcile real rsync failures from worker logs
Record live rsync log paths for normal backup runs so the worker can
recover stale running state after terminal rsync errors.

Treat rsync vanished-file exit code 24 as a warning and keep the
completed snapshot instead of failing the run into incomplete state.

Closes #54
2026-05-23 00:23:14 +02:00
8633cbea26 Merge pull request '(bugfix) Quote remote preflight shell commands' (#46) from issue-45-preflight-shell-quoting into master
Reviewed-on: #46
2026-05-21 15:45:46 +02:00
3fb8209aef (bugfix) Quote remote preflight shell commands
Pass remote rsync and source-root preflight checks as a single quoted
shell command to SSH so the remote shell evaluates command -v and test
expressions reliably.

Refs #45
2026-05-21 15:44:46 +02:00
833edb2466 Merge pull request '(release) Prepare pobsync 1.1.0' (#44) from release/1.1.0 into master
Reviewed-on: #44
2026-05-21 15:24:55 +02:00
c7e9e69345 (release) Prepare pobsync 1.1.0
Bump the package and runtime version to 1.1.0, add release notes for
the UI-focused control panel update, and refresh version assertions for
the CLI entrypoint.

Refs 1.1
2026-05-21 15:23:50 +02:00
e79d871f36 Merge pull request 'issue-36-lightweight-live-refresh' (#43) from issue-36-lightweight-live-refresh into master
Reviewed-on: #43
2026-05-21 15:18:46 +02:00
ad45fbe46e (feature) Add live refresh for dashboard status panels
Split dashboard priority and host status sections into server-rendered
partials and wire them into the shared refresh hook so operational state
updates without a full page reload.

Refs #36
2026-05-21 15:17:11 +02:00
3cac7b61ac (feature) Add live refresh for run detail status
Add a server-rendered run detail partial and a small vanilla JavaScript
refresh hook so active backup runs update status, controls, timing, and
rsync log output without a full page reload.

Document the Django-template-first refresh pattern for future control
panel work.

Refs #36
2026-05-21 15:10:37 +02:00
1d6c21764b Merge pull request 'issue-37-clean-primary-navigation' (#42) from issue-37-clean-primary-navigation into master
Reviewed-on: #42
2026-05-21 15:02:02 +02:00
6f392bef65 (ui) Highlight current navigation section
Mark the active primary or system navigation link with aria-current and a
subtle visual state so staff users can see where they are in the control
panel without making Admin a primary app route.

Refs #37
2026-05-21 15:01:14 +02:00
6035c547ae (ui) Split primary and system navigation
Move operator routes into a primary navigation group and demote admin,
self-check, changelog, and status API links into a secondary system group
so the header reflects the control panel workflow more clearly.

Refs #37
2026-05-21 14:56:30 +02:00
a3a8fea071 Merge pull request 'issue-38-dashboard-responsive-layout' (#41) from issue-38-dashboard-responsive-layout into master
Reviewed-on: #41
2026-05-21 14:51:34 +02:00
0e2f48ab65 (ui) Remove dashboard panel overflow traps
Let dashboard-specific panels size naturally instead of inheriting generic
panel overflow behavior, and tighten activity and host card sizing so long
content wraps without creating internal scrollbars.

Refs #38
2026-05-21 14:50:05 +02:00
b55950e24a (ui) Add dashboard section responsive hooks
Give dashboard summary, trends, and host sections dedicated layout hooks
and tighten their responsive behavior so metrics and host cards remain
readable on narrower screens.

Refs #38
2026-05-21 14:46:23 +02:00
025cd0336c (ui) Harden dashboard responsive layout
Rework the dashboard priority grid to avoid cramped four-column layouts,
prevent stretched empty panels, and make long host and snapshot text wrap
safely across dashboard cards.

Refs #38
2026-05-21 14:43:24 +02:00
4c76ae9f52 Merge pull request 'Polish forms and action flows' (#35) from issue-25-forms-action-flows into master
Reviewed-on: #35
2026-05-21 14:28:20 +02:00
7a552715fe (ui) Clarify run action flows
Move run cancellation and review actions out of the page header into
dedicated action panels with clearer operator copy and consistent form
button styling.

Refs #25
2026-05-21 14:25:26 +02:00
0f0de5dc30 (ui) Standardize list filter actions
Give run, snapshot, schedule, purged snapshot, and log filters the same
responsive form layout with consistent Apply/Clear actions.

Refs #25
2026-05-21 14:22:11 +02:00
1604f0f6f4 (ui) Clarify destructive action flows
Make retention apply, incomplete cleanup, and SSH key deletion visibly
destructive with warning copy, danger styling, and consistent cancel actions
while keeping the existing confirmation requirements intact.

Refs #25
2026-05-21 14:17:07 +02:00
af548f11c4 (ui) Standardize primary form actions
Add shared form action styling and consistent Cancel links across config,
schedule, and SSH key forms so create/edit flows behave predictably.

Refs #25
2026-05-21 14:13:05 +02:00
c0eca3da55 Merge pull request 'issue-26-host-detail-control-page' (#34) from issue-26-host-detail-control-page into master
Reviewed-on: #34
2026-05-21 14:05:51 +02:00
212813e066 (ui) Make host diagnostics easier to scan
Replace the raw host check table with diagnostic cards and group effective
runtime config into operator-focused sections for backup target, connection,
and selection/retention details.

Refs #26
2026-05-21 14:03:43 +02:00
ab5291b8d3 (ui) Show host runs and snapshots as record cards
Replace the database-style Latest Runs and Snapshots tables on the host
detail page with scannable record cards and host-filtered View all links.

Refs #26
2026-05-21 13:59:16 +02:00
1929196287 (ui) Group host detail actions by context
Move host-level actions out of the page header and into the panels where
operators expect them: configuration, connection preflight, and snapshot
storage. This keeps the host control page calmer while preserving the same
actions.

Refs #26
2026-05-21 13:53:10 +02:00
9e75273fc5 (ui) Promote host detail operator controls
Add a first-screen host control workspace with status, backup actions,
schedule state, and current activity so the host detail page behaves as the
primary operator page instead of starting with raw configuration blocks.

Refs #26
2026-05-21 13:40:37 +02:00
5dd6ebb3db Merge pull request 'issue-27-dashboard-information-architecture' (#33) from issue-27-dashboard-information-architecture into master
Reviewed-on: #33
2026-05-21 13:29:18 +02:00
864a40e862 (ui) Surface storage pressure in dashboard priorities
Move backup root usage, runway, daily new data, and available capacity into
the top dashboard priority area so storage risk is visible before deeper trend
details.

Refs #27
2026-05-21 13:27:39 +02:00
9412feaa58 (ui) Rework dashboard around operator priorities
Move required actions, upcoming scheduled work, and recent run activity to the
top of the dashboard so the first screen answers what needs attention next.
Keep summary metrics, trends, and host cards as supporting drill-down content.

Refs #27
2026-05-21 13:21:09 +02:00
0fe2aa439f Merge pull request '(ui) Add review actions to filtered run lists' (#32) from issue-22-operational-review-actions into master
Reviewed-on: #32
2026-05-21 13:12:20 +02:00
fe4ae9d147 (ui) Add review actions to filtered run lists
Add inline Mark reviewed actions for failed and warning runs on the run list,
preserving active filters after review so Operational Status drill-downs can
be cleared without opening every run detail page.

Refs #22
2026-05-21 13:07:45 +02:00
0a3a3448d6 Merge pull request 'Make dashboard cards link to operational lists' (#31) from issue-23-dashboard-list-pages into master
Reviewed-on: #31
2026-05-21 12:49:15 +02:00
01b779c862 (ui) Add schedule overview for dashboard drill-down
Add a staff-only schedules page with filters for host, enabled state, and
prune state, including next run and last scheduler state.

Wire the dashboard Schedules metric to the new overview so all primary
dashboard count cards have useful destinations.

Refs #23
2026-05-21 12:39:57 +02:00
67d1af0baa (ui) Make dashboard operational status actionable
Turn the dashboard operational status rows into direct links to filtered run
lists, so failed, warning, running, and queued states can be investigated from
the first screen.

Also move the hosts anchor back to the actual Hosts section.

Refs #23
2026-05-21 12:00:06 +02:00
4e8e4f75fd (ui) Add dashboard-linked run and snapshot lists
Add staff-only list pages for backup runs and snapshots with practical
filters, then wire the dashboard summary cards and latest-runs panel to
those overviews.

This gives the dashboard real drill-down paths for run and snapshot counts
instead of leaving the data only partially visible on the first screen.

Refs #23
2026-05-21 11:52:35 +02:00
2be2d11b4a Merge pull request 'Create cohesive control panel redesign' (#30) from issue-28-control-panel-redesign into master
Reviewed-on: #30
2026-05-21 11:44:28 +02:00
b67ae7ff8b (ui) Extend page headers across utility views
Apply the shared page-header pattern to configuration, access,
operations, retention, log, and changelog pages so the control panel
uses one consistent title, context, and action structure.

Add representative view assertions for the new page context on utility
pages.

Refs #28
2026-05-21 11:42:01 +02:00
ad2cc5585e (ui) Add consistent page headers to key views
Introduce a shared page-header pattern with kicker, title, subtitle, and
actions, then apply it to the dashboard, host detail, run detail, snapshot
detail, and retention plan pages.

Scope the global app header styles to avoid leaking sticky navigation styles
onto page-level headers, and add view assertions for the new page context.

Refs #28
2026-05-21 11:37:25 +02:00
8aa3f1d1f5 (ui) Establish cohesive control panel styling
Refresh the shared base styling so the Django control panel has a calmer,
more polished production-tool feel across all pages. Update typography,
navigation, panels, metrics, host cards, tables, forms, buttons, messages,
focus states, and responsive behavior through reusable CSS variables and
component styles.

Refs #28
2026-05-21 11:31:49 +02:00
30cf93df27 Merge pull request 'Remove legacy-facing UI labels' (#29)
Reviewed-on: #29
2026-05-21 11:19:48 +02:00
01c4ccb316 (ui) Remove legacy-facing labels from operator pages
Replace refactor-era wording such as Source, Source root, SQL records,
database, runtime, and Django generation labels with operator-facing copy
around backup source, tracking records, changelog files, active config, and
pobsync-managed SSH keys.

Add view assertions so the old source/SQL labels do not quietly return.

Refs #24
2026-05-21 11:13:10 +02:00
00d4f2a70b Merge pull request 'release-hardening_1.0' (#21) from issue-8-release-hardening into master
Reviewed-on: #21
2026-05-21 03:56:24 +02:00
f8215a0c9a (release) Update 1.0 changelog for hardening work
Bring the 1.0.0 release notes up to date with the release-hardening work
completed after the initial metadata pass: worker heartbeat tracking,
incomplete snapshot cleanup, review resolution, SSH key management hardening,
purged snapshot audit history, and the in-app changelog page.

Refs #8
Refs #10
Refs #11
Refs #16
Refs #19
Refs #20
2026-05-21 03:51:27 +02:00
ea9e3e41e3 (release) Add purged snapshot audit overview
Record snapshot purge history whenever retention or incomplete cleanup removes
snapshot directories and SQL records. Store the purge reason, original kind,
path, action source, and triggering operator so manual, scheduled, CLI, and
incomplete cleanup actions remain auditable after the original snapshot record
is deleted.

Add a staff-only Purged Snapshots page with host/action filters and register
the audit model in Django admin.

Refs #16
Refs #8
2026-05-21 03:46:38 +02:00
5b5a5bc637 (release) Harden SSH key edit and delete flow
Make SSH credential management more explicit by adding an edit action in the
key overview and requiring name confirmation before deletion. Keep deletion
blocked while a key is still selected by hosts or global config, and cover
rename, delete confirmation, and in-use protection in view tests.

Refs #20
Refs #8
2026-05-21 03:38:55 +02:00
c2e5a534aa (release) Add review resolution for operational tasks
Add reviewed state for failed/warning runs and incomplete snapshot records,
then use it to clear dashboard and host “need review” tasks after an operator
has acknowledged them.

Expose Mark reviewed actions on run detail and host retention warnings, keep
reviewed records available for audit/debug, and exclude reviewed problem runs
from operational counts and latest issue summaries.

Refs #19
Refs #8
2026-05-21 03:34:41 +02:00
d0c23deb72 (release) Add explicit incomplete snapshot cleanup
Add a dedicated cleanup path for incomplete snapshots instead of letting
retention prune them implicitly. The retention plan now exposes a guarded
form that requires host and delete-count confirmation before removing
.incomplete snapshot directories and their SQL records.

Keep scheduled/manual retention behavior unchanged, add path safety checks,
and cover cleanup success, confirmation failures, max-delete limits, and
unexpected paths in tests.

Refs #10
2026-05-21 03:26:21 +02:00
4c8ed24561 (release) Track worker heartbeat for running jobs
Record worker pid, host, claim time, and heartbeat metadata on running
backup jobs so operators can see which worker owns a run.

Refresh the heartbeat while rsync is active and reconcile stale running
runs when the worker heartbeat stops. Add a worker option to tune or
disable stale-run reconciliation.

Refs #11
2026-05-21 03:16:38 +02:00
404b7f7500 (release) Add Django changelog page
Expose the repository CHANGELOG.md through a staff-only Django view and
link it from the main navigation.

Render a small safe subset of Markdown without adding a runtime dependency,
copy the changelog into the Docker image, and cover the page with view tests.
2026-05-21 03:10:31 +02:00
beca073ddc (release) Prepare 1.0.0 release metadata
Add the initial 1.0.0 changelog, bump the package/application version,
and expose the release version through `pobsync --version`.

Cover the version output in the console entrypoint tests.
2026-05-21 03:04:59 +02:00
362a9dde62 Merge pull request 'issue-7-config-cleanup-legacy-removal' (#18) from issue-7-config-cleanup-legacy-removal into master
Reviewed-on: #18
2026-05-21 02:57:38 +02:00
a73d34ac9f (refactor) Use operator-facing config errors
Replace remaining model-name based configuration errors with labels that
match the Django-first operating model.

Add coverage for missing global config and host configuration errors so
operator-facing messages stay readable.
2026-05-21 02:56:00 +02:00
1c8cbd96ca (refactor) Normalize maintainer command labels
Prefer --schedule-expression for scripted schedule updates while keeping
--cron as a compatibility alias.

Clean up management command help, errors, and output so operator-facing
text talks about hosts, global config, and Django backup configuration
instead of model names or old SQL-backed pobsync wording.
2026-05-21 02:52:42 +02:00
86873bd035 (refactor) Remove obsolete global config JSON storage
Drop the unused GlobalConfig.data field and remove the remaining YAML
config path helpers from PobsyncPaths.

Keep HostConfig.config as runtime state for preflight data, and relabel it
in the admin so it no longer reads as legacy compatibility storage.
2026-05-21 02:46:09 +02:00
2642f14e49 (refactor) Remove pobsync_home from global config
Drop the obsolete pobsync_home field from GlobalConfig and remove it from
runtime config generation, form saves, and configuration commands.

The runtime state root now comes exclusively from POBSYNC_HOME/settings,
which keeps the Django model focused on backup behavior instead of install
layout.
2026-05-21 02:41:02 +02:00
bb62382e18 (refactor) Remove YAML config import and export path
Drop the pre-Django YAML import/export management commands and remove the
file-based config loader fallback from the backup and retention engines.

Keep the runtime config bridge backed by Django models, and add tests that
ensure engine operations require an explicit Django config source.
2026-05-21 02:34:09 +02:00
c5865a5379 (refactor) Normalize runtime config labels
Hide the old pobsync_home field from the Django admin and replace legacy
operator-facing labels with runtime state root and backup root terminology.

Rename admin compatibility fieldsets, update self-check/config-check text,
and refresh management command help so Django/systemd stays the primary
mental model.
2026-05-21 02:24:55 +02:00
58d567f9bc (refactor) Retire configuration command aliases
Remove the short pobsync aliases for global config, host config, and
schedule changes so the public CLI no longer points operators toward the
old configuration workflow.

Keep operational aliases for backup, discovery, retention, worker, and
scheduler debugging, and document explicit Django management commands for
automation use.
2026-05-21 02:14:16 +02:00
2d9f453767 Merge pull request 'issue-6-restore-story' (#17) from issue-6-restore-story into master
Reviewed-on: #17
2026-05-21 02:06:22 +02:00
20a9f93378 (docs) Add targeted restore examples
Extend the restore guidance with directory and single-file dry-run
examples so operators can restore a focused path without copying an
entire snapshot.

Render the examples on snapshot detail pages using the selected
snapshot's data path and the host-specific staging destination.
2026-05-21 02:05:19 +02:00
b78f102e9d (docs) Add manual restore guidance for snapshots
Document the manual restore workflow in the README and surface snapshot-
specific restore commands on the snapshot detail page.

The guidance keeps restores intentionally manual for now: inspect the
snapshot data directory, run rsync with --dry-run, restore to staging
first, and treat hardlinked snapshot files as read-only.
2026-05-21 02:01:40 +02:00
8858e049ee Merge pull request '(ui) Add dashboard operational status summary' (#15) from issue-5-dashboard-1-0 into master
Reviewed-on: #15

This closes issue #5
2026-05-21 01:54:48 +02:00
a75b97c4c0 (ui) Add dashboard operational status summary
Make queued, running, warning, and failed run states more visible at the
top of the dashboard with contextual status summaries and highlighted
summary metrics.

Also show an all-clear message when configured hosts have no active or
problematic runs.
2026-05-21 01:51:13 +02:00
b4fc5a14b2 (ui) Clarify dashboard backup growth trends
Replace the dashboard trend metric grid with an operational summary that
explains storage usage, runway, average new data, link-dest savings, and
average duration in a more readable way.

Also add an empty state for fresh installs before completed backup stats
exist.
2026-05-21 01:46:49 +02:00
a0fd33fcb8 (ui) Improve dashboard host card scanability
Add per-host status chips for queued, running, warning, and failed runs so
the dashboard shows operational pressure without needing to open each host.

Restructure host cards into clearer backup activity and snapshot health
sections, with less visual clutter and better mobile wrapping.
2026-05-21 01:41:45 +02:00
ef1761385e (ui) Separate healthy and problematic dashboard runs
Split dashboard host cards into last successful backup and latest warning
or failed run so operators can quickly see whether a host is protected even
when recent activity produced an issue.

Also add queued and warning run counts to the dashboard summary metrics.
2026-05-21 01:34:38 +02:00
17215fd191 Merge pull request '(feature) Improve retention UX and prune safety' (#14) from issue-4-retention-ux-and-safety into master
Reviewed-on: #14
2026-05-21 01:27:19 +02:00
97753c3d3c (ui) Show retention apply details on run detail
Record planned delete counts, max-delete settings, base protection, and
ignored incomplete snapshots in retention apply results.

Surface those details on run detail pages so scheduled and manual prune
outcomes are understandable without reading the raw JSON payload.
2026-05-21 01:25:40 +02:00
994f7f66c4 (bugfix) Preserve scheduled backup warning status
Update the scheduler to reflect the actual scheduled BackupRun status after
a run completes, so prune warnings are shown as schedule warnings instead
of being reported as successful schedule executions.
2026-05-21 01:22:06 +02:00
f76b6cad14 (feature) Require delete count confirmation for retention apply
Make manual retention application more explicit by requiring operators to
confirm both the host name and the current number of planned deletions.

This reduces the risk of applying a stale or misunderstood retention plan
when the delete set changes between review and confirmation.
2026-05-21 01:19:08 +02:00
90e293facd (ui) Surface retention warnings on run detail
Show host-level retention warnings on run detail pages so successful or
warning runs still expose scheduled prune limit issues and incomplete
snapshots that need operator attention.
2026-05-21 01:13:44 +02:00
50eb7cf2f3 (ui) Make retention planning warnings explicit
Show keep/delete reasons in the retention plan, surface scheduled prune
limit warnings, and explain base snapshot protection before retention is
applied.

Also surface incomplete snapshots from the retention views without deleting
them automatically, so interrupted backups are visible on the dashboard,
host detail, and retention plan.
2026-05-21 01:10:45 +02:00
26265be440 Merge pull request '(feature) Add backup safety and preflight validation' (#13) from issue-3-backup-safety-preflight-validation into master
Reviewed-on: #13
2026-05-21 00:58:23 +02:00
5faef1492d (ui) Add readable dry-run summaries
Surface dry-run status, transfer estimates, file counts, warnings, and the full
rsync log link directly on the run detail page.

Keep raw rsync output and JSON available, but make the common review path easier
to scan before starting a real backup.
2026-05-21 00:55:19 +02:00
3045093dcf (feature) Add remote host connection preflight
Add an on-demand host preflight action that verifies SSH reachability,
remote rsync availability, and remote source root access.

Persist the latest preflight result on the host config, render it in Django,
and block real backups when the last remote preflight failed.
2026-05-21 00:50:05 +02:00
64a0ff8322 (feature) Add host backup preflight gates
Introduce a host preflight layer that separates dry-run blockers from real backup blockers.
Show the effective per-host backup configuration in Django before queueing a run.

Block real backup queueing when failed host checks remain, while still allowing dry-runs
when only local storage preparation is missing.
2026-05-21 00:41:45 +02:00
155ff63a73 Merge pull request '(feature) Improve run debugging and log filtering' (#12) from issue-2-run-detail-logging-polish into master
Reviewed-on: #12
2026-05-21 00:26:45 +02:00
a9e40df44b (feature) Add focused filtering to the service logs view
Extend the Django logs view with filters for service unit, severity, time
window, host, run id, and message text. Pass severity and time window directly
to journalctl, then apply host/run/message filtering to the returned pobsync
journal lines.

This makes failed or slow backups easier to investigate from the control panel
without needing shell access.
2026-05-21 00:24:07 +02:00
98695f9888 (ui) Surface run debugging details in Django
Restructure the run detail page into clearer sections for summary, failure
classification, requested options, rsync command, rsync log output, stats,
retention, and raw result data.

Show recent rsync log output inline with a link to the full log, and promote
failure and retention warning details out of the JSON payload so failed or slow
runs are easier to debug from the control panel.
2026-05-21 00:17:39 +02:00
d8c0ee5d1e Merge pull request '(ops) Complete native production install and update flow' (#9) from issue-1-production-install-update into master
Reviewed-on: #9
2026-05-20 01:50:23 +02:00
851f967f12 (ops) Expand native install self checks and recovery docs
Extend the runtime self check with native install diagnostics for the
environment file, service user, backup root ownership, and SQLite database
path. Export install metadata from the systemd units and pobsync-manage wrapper
so custom env files and service users are visible to Django checks.

Document restart, journal log inspection, and rollback steps in the README so
production updates have a clear recovery path.
2026-05-20 01:44:51 +02:00
c97c595253 (ops) Add terminal self-check command for native installs
Add check_pobsync_install so native deployments can run the same runtime
diagnostics from the terminal that are available in the Django Self Check view.

The command prints every check with status, returns a failing exit code when
install-critical checks fail, supports fail-on-warning for stricter automation,
and is documented in the installer output and README update flow.
2026-05-20 01:37:07 +02:00
f5acdf2fff (refactor) Remove obsolete source-copy deploy script
Delete the old scripts/deploy path that installed pobsync into /opt/pobsync/lib
with a standalone /opt/pobsync/bin wrapper.

Production deployment is now owned by the native systemd installer and updater,
so keeping the legacy deploy script would make the supported install story less
clear and easier to misuse.
2026-05-20 01:33:07 +02:00
e6ed7954de (ops) Add native update wrapper for production deploys
Add scripts/update-systemd as a safer routine deploy entrypoint for native
systemd installations. The wrapper keeps updates non-interactive, preserves the
existing environment file, skips OS package installation, and avoids superuser
creation prompts while still reusing the installer refresh flow.

Document the update path in the README and capture the maintenance expectation
in the development notes.
2026-05-20 01:30:02 +02:00
73e6bb7285 (ops) Install pobsync-manage for native management commands
Add a pobsync-manage wrapper that loads the native environment file before
running Django management commands, so production commands use the same
database and runtime settings as the systemd services.

Install the wrapper from the systemd installer, use it for migrations,
static collection, SSH key setup, and superuser creation, and document it
in the README for operational commands.
2026-05-20 01:27:08 +02:00
d451d01fe2 (docs) Move 1.0 roadmap tracking to Gitea
Create the 1.0 milestone and roadmap issues in Gitea, making Gitea the
leading tracker for release work.

Remove the repository roadmap document and README reference now that the
roadmap is tracked as milestone issues.
2026-05-20 01:17:35 +02:00
bfe17969e6 (docs) Add pobsync 1.0 roadmap
Document the remaining 1.0 work as ordered, reviewable chunks with
checkboxes for install/update flow, logging, preflight validation,
retention UX, dashboard polish, restore docs, config cleanup, and release
hardening.

Link the roadmap from the README and note how it can later be mirrored into
Gitea milestones and issues.
2026-05-20 01:07:17 +02:00
7cd87bc8a8 (ui) Remove global config count from dashboard summary
Drop the Global Configs metric from the dashboard because pobsync only uses
one default global configuration.

Keep global config management available through the existing dashboard
action and setup prompt.
2026-05-20 00:29:43 +02:00
0babc57f57 (feature) Link rsync logs from backup run detail
Record the final rsync log path for successful real backup runs, matching
the existing dry-run and failure result payloads.

Add a staff-only run log endpoint and surface the link on run detail pages,
including fallback log discovery for older runs based on snapshot_path.

Cover direct log links and inferred scheduled backup logs with view tests.
2026-05-20 00:09:59 +02:00
f41e59e695 (ui) Relax dashboard host card layout
Split each dashboard host card into a timeline area and a compact stats area
so snapshot, run, and schedule details have more room to scan.

Keep the same operational information visible while reducing cramped
column-like wrapping on desktop and mobile layouts.
2026-05-19 23:52:54 +02:00
f3dcb8a3b9 (ui) Replace dashboard host table with host cards
Restructure the dashboard Hosts section into a card per host so snapshot,
run, schedule, retention, and data metrics have room to breathe.

Add reusable host-card styling and keep the same links and operational data
available without forcing everything into a wide table.
2026-05-19 23:46:52 +02:00
c2d7342a47 (refactor) Remove unused schedule user field
Drop the legacy schedule user setting from the Django model, form, defaults,
and configure command.

Schedules are executed by the pobsync scheduler service under the configured
systemd service user, while remote SSH login users are configured separately
on global or host backup config.

Add a migration to remove the unused database column and update schedule
view tests around the simplified form.
2026-05-19 23:41:55 +02:00
5ca2733ea9 (bugfix) Prune snapshots with preserved read-only permissions
Make SQL retention delete the snapshot root when records point at the
snapshot data directory, matching how backup metadata is stored on disk.

Before removing a snapshot tree, temporarily add user write permission to
directories inside that snapshot so rsync-preserved source permissions do
not block cleanup.

Add a regression test for pruning snapshots whose data directory mirrors a
read-only remote root.
2026-05-19 23:29:36 +02:00
8bff241b12 (bugfix) Mark retention failures as backup warnings
Add a warning status for BackupRun records so successful snapshots are not
reported as failed when post-run SQL retention fails.

Keep the prune error in the run result, link the successful snapshot, and
let the management command complete with a warning instead of raising a
backup failure.

Include warning runs in backup trend summaries and add a regression test
for successful backups with failed retention cleanup.
2026-05-19 23:20:52 +02:00
1e04da9de8 (bugfix) Preserve existing schedule values in the edit form
Only apply default schedule initial values when creating a new schedule.

Avoid passing default initial data while editing an existing ScheduleConfig,
so the form renders the active cron-style expression, user, and retention
settings from the database.

Add a regression test that reopens an existing schedule and verifies the
stored values are shown instead of defaults.
2026-05-19 23:13:53 +02:00
39b6cf3469 (feature) Add scheduler timezone setup to the native installer
Prompt for the scheduler timezone during interactive installs and support
a --time-zone flag for scripted installs.

Detect a sensible default from POBSYNC_TIME_ZONE, timedatectl, or
/etc/timezone before falling back to UTC, and validate the value with
Python zoneinfo before writing it to pobsync.env.

Document that schedules are evaluated in POBSYNC_TIME_ZONE so production
installs can avoid UTC/local-time ambiguity.
2026-05-19 23:09:15 +02:00
124421b85c (bugfix) Show latest successful runs without requiring stats
Separate operational latest-run display from trend-stat collection so
successful backups without parsed stats still appear in dashboard host rows.

Keep trend summaries limited to runs with stats, but use all successful
real runs for the host latest-run indicator.

Render next scheduled run times with an explicit timezone label to avoid
ambiguity between UTC and local scheduler time.
2026-05-19 23:05:22 +02:00
86eee0f916 (feature) Show next scheduled run and backup run type in the UI
Add a scheduler helper that calculates the next due time for a cron-style
schedule expression and surface that value on the dashboard and host detail
pages.

Show the latest run type in host summaries and backup trend tables so
manual and scheduled backups are distinguishable in the Django UI.

Keep the calculation derived from existing ScheduleConfig data without
adding a migration.
2026-05-19 22:57:58 +02:00
9624fb469f (refactor) Clarify scheduler terminology in the Django UI
Rename the schedule form label from "Cron expression" to "Schedule
expression" and explain that cron-style timing is evaluated by the
pobsync scheduler service rather than host cron.

Update host detail and schedule form copy so operators can see that
schedules are SQL-backed and dispatched by pobsync itself.
2026-05-19 22:48:00 +02:00
e8169eae42 (feature) Add backup trend forecasting to the Django UI
Extend derived backup statistics with average daily new data and an
estimated days-until-full forecast based on recent successful real runs.

Show the new forecast metrics on the dashboard and add compact per-run
trend bars on the host detail page so new data and matched link-dest data
are easier to compare at a glance.

Keep the implementation migration-free by deriving everything from the
existing BackupRun result stats payload.
2026-05-19 22:39:46 +02:00
fc22842fc4 (feature) Summarize backup trends in the Django UI
Add a stats summary layer that aggregates recent successful real backup runs
into dashboard and host-level trend metrics.

Show backup-root usage, available space, average new data, average duration,
estimated runs until full, and link-dest savings on the dashboard. Add a host
trend table with recent run duration, file count, new data, matched data, and
snapshot links.

Keep the implementation based on existing run and snapshot stats JSON so the
UI gains useful trend visibility without introducing a schema migration yet.
2026-05-19 22:31:24 +02:00
6940dc55b7 (feature) Capture structured backup statistics
Parse rsync --stats output into structured run metrics for file counts,
transferred bytes, literal data, matched data, speedup, and estimated
link-dest savings.

Store collected stats on backup run results and successful snapshot metadata,
including snapshot data usage and backup-root capacity details for future
dashboard graphs and disk-full projections.

Render the collected metrics on run and snapshot detail pages, with tests
covering parsing, metadata persistence, and UI output.
2026-05-19 22:25:04 +02:00
728e5c740a (feature) Add optional verbose rsync output for manual backups
Expose a verbose rsync output option in the Django manual backup form and
store the selected value with the queued run request.

Propagate the option through the worker, direct management command, and
rsync command builder so real backups can emit itemized changes, file-list
progress, and stats when requested. Dry-runs continue to use verbose output
by default and report that consistently in requested options.

Cover the queue, worker, view, and rsync command behavior with focused
tests.
2026-05-19 22:13:33 +02:00
d52a9167d1 (bugfix) Reconcile failed dry-runs from rsync terminal logs
Classify rsync failures in run results so transport issues such as exit
255 and broken pipes show clearer diagnostic hints.

Teach the worker to reconcile running dry-runs when their log already
contains a terminal rsync error, and to fail stale dry-runs after their
timeout window. This prevents failed rsync processes from leaving runs
stuck in the running state indefinitely.
2026-05-19 21:10:08 +02:00
d67ba9cada (feature) Add managed rsync verbosity for dry-runs
Add default dry-run rsync output flags so long-running dry-runs expose
file-list, progress, stats, and itemized change information in their
run-specific log files.

Avoid duplicating user-supplied itemize or --info arguments so operators
can still tune rsync output from global or host configuration.
2026-05-19 20:57:29 +02:00
bbb0f652f3 (feature) Add cancellable backup runs and clearer dry-run logs
Add a cancel action for queued and running backup runs. Queued runs are
cancelled immediately, while running runs are marked for cancellation and
the worker terminates the active rsync process group.

Make dry-run log paths run-specific and add a defensive default dry-run
timeout so stuck dry-runs do not remain running indefinitely.

Remove rsync exit codes from run overview tables while keeping detailed
diagnostics available on the run detail payload.
2026-05-19 20:46:10 +02:00
088f43279e (feature) Add global and effective host config checks
Introduce reusable configuration checks for global settings and effective
host runtime configuration. The checks now surface risky backup settings
such as missing recursive rsync args, missing critical root excludes,
invalid SSH settings, missing credentials, and retention gaps.

Show these checks on the global config form, host edit form, and host
detail page so operators can validate the compounded host/global config
before starting real backup runs.
2026-05-19 20:24:29 +02:00
7e5d31d53b (bugfix) Guard dry-run logs and recursive rsync defaults
Clear the reused dry-run rsync log before each dry-run so run details
only show output from the current execution.

Populate new Django global configs with the existing safe rsync and
exclude defaults, including archive mode and standard pseudo-filesystem
exclusions.

Add a host check that fails when effective rsync args do not include
archive or recursive transfer, preventing real backups that only report
"skipping directory .".
2026-05-19 20:09:35 +02:00
8bd2a8ff1a (bugfix) Use service-level known_hosts for generated SSH keys
When a selected SSH credential has no pinned known_hosts entries, create
and use a pobsync service-level known_hosts file under POBSYNC_HOME/state.

Pass UserKnownHostsFile and StrictHostKeyChecking=accept-new to SSH so
unattended backups no longer depend on root's known_hosts or an
interactive shell session.

Keep pinned credential known_hosts behavior unchanged when entries are
configured explicitly.
2026-05-19 20:01:39 +02:00
d3ffca1843 (feature) Add host key scanning for SSH credentials
Add a host detail action that scans the target SSH host key with
ssh-keyscan and stores it on the selected SSH credential.

Merge scanned known_hosts entries without duplicates and let the
existing runtime config pass them through as UserKnownHostsFile for
unattended rsync over SSH.

Extend host checks to warn when the selected credential has no known_hosts
entries, making host key verification failures actionable from Django.
2026-05-19 19:55:40 +02:00
25d2a5b1a7 (bugfix) Surface rsync SSH failure details in run results
Include the selected SSH credential metadata and rsync log tail in
dry-run and failed backup results so Django shows the actual SSH or
rsync failure instead of only the exit code.

Warn in host checks when a host still uses database-stored private key
material, making it easier to spot old credentials after switching to
generated filesystem keys.
2026-05-19 19:49:33 +02:00
df3dcc47c9 (feature) Generate filesystem-backed SSH credentials
Add filesystem-backed SSH credentials for the native systemd deployment
path. Generated keys are stored below POBSYNC_HOME with 0600
permissions, while Django keeps the public key, fingerprint, path, and
selection metadata.

Add a Django SSH key generation view, delete action for unused generated
keys, and a management command used by the installer to ensure a default
backup key exists.

Update runtime config to use generated key paths directly as IdentityFile,
extend host checks to verify key readability, and keep legacy uploaded
keys available for compatibility.
2026-05-19 19:41:40 +02:00
ccacad3d37 (bugfix) Grant service user backup and journal access
Update the native installer so the pobsync service user gets journal
read access when the host exposes systemd-journal or adm groups.

Apply ownership and private directory modes to the configured backup
root, and reuse the existing environment backup root on reinstall so
production updates do not fall back to /backups.

Add a self-check for journal access and a host detail action that can
prepare missing backup directories for existing host configurations.
2026-05-19 19:25:05 +02:00
90f28410ce (feature) Add host doctor checks and Django log viewer
Add host-level checks for address, enabled state, SSH credential
selection, and backup directory readiness, and show them on the host
detail page.

Create host backup directories during host creation and prefill new
hosts from the default global config.

Add a staff-only logs view backed by journalctl with filtering by
pobsync unit, priority, and message text.

Improve runtime checks for gunicorn in virtualenv installs and ensure
the native installer grants the service user access to the backup root.
2026-05-19 19:11:57 +02:00
bb7907846e (bugfix) Restart systemd services during native updates
Change the native installer to enable services and then explicitly
restart web, worker, and scheduler so updated Django code is loaded
after each install or update.

Document that the installer refreshes app files and restarts systemd
services as part of the normal update flow.
2026-05-19 18:58:41 +02:00
96b91b2a69 (refactor) Quiet native installer output by default
Add step-based installer logging that reports pobsync actions as OK,
FAILED, or SKIPPED while suppressing noisy apt, pip, Django, and
systemd output during successful runs.

Show the captured output for the failed step when something breaks,
and add a --verbose flag to restore full command output for debugging.

Document the quieter installer behavior and verbose mode in the README
and development notes.
2026-05-19 18:52:31 +02:00
98d152da06 (bugfix) Add SSH private key file upload
Allow SSH credentials to be created from an uploaded private key file
as an alternative to pasting the key into a textarea.

Use multipart form handling in the credential views so server-side
keys can be imported without copy/paste wrapping or formatting damage.

Cover the upload path with a view test while keeping existing pasted
key validation behavior intact.
2026-05-19 18:48:17 +02:00
97797c574d (bugfix) Normalize pasted OpenSSH private keys
Canonicalize uploaded OpenSSH private keys before validation by
normalizing line endings, removing whitespace from the base64 body,
and re-wrapping it between the BEGIN and END markers.

Add SSH credential tests that generate a real ed25519 key, damage its
wrapping, and verify that validation succeeds after normalization.

Return a clearer validation error for PEM private keys, which are not
supported by the current credential flow.
2026-05-19 18:42:02 +02:00
c7cfb603b0 (bugfix) Improve SSH credential validation feedback
Normalize pasted private keys before validation and detect common SSH
credential mistakes, including public keys pasted into the private key
field and public keys that do not match the supplied private key.

Translate OpenSSH libcrypto parse failures into a clearer user-facing
message and disable browser spellcheck/autocomplete on SSH key fields.

Document the native update flow as git pull followed by the
non-interactive installer so deployments refresh cleanly.
2026-05-19 18:35:39 +02:00
38f946d1c4 (feature) Make the native installer interactive
Add an interactive setup flow to the systemd installer with defaults
for install paths, service identity, backup storage, bind address,
allowed hosts, CSRF origins, OS package installation, MariaDB support,
nginx setup, and first superuser creation.

Keep scripted installs supported through non-interactive mode, existing
overrides, environment variables, and explicit superuser flags.

Print a user-facing completion summary with the control panel URL,
Self Check reminder, first setup steps, and useful service log commands.
2026-05-19 18:22:18 +02:00
44d821c638 (docs) Reframe documentation around Django-first operations
Update the README to describe pobsync as a Django-first, SQL-backed
service with the control panel as the primary operational interface.

Move CLI examples out of the normal workflow and document them as
maintainer tooling for debugging, services, and migration tasks.
2026-05-19 18:17:43 +02:00
b1789d8621 (config) Install native runtime requirements and split docs
Teach the systemd installer to install required Debian/Ubuntu
packages by default, with an opt-out for users who manage system
dependencies themselves.

Add explicit MariaDB install-extra handling so native installs can
pull in both the Python extra and required client build packages.

Slim down the README to production setup and operations, and move
development, Docker, migration helper, and architecture notes into
docs/development.md.
2026-05-19 18:15:34 +02:00
372a857f15 (feature) Add full native installer and self-check page
Expand the systemd installer so it can perform a complete native
installation with sensible defaults: copy the checkout into the target
app directory, create runtime directories, write the environment file,
install dependencies, configure systemd units, and optionally configure
nginx.

Add a staff-only Django self-check page that verifies runtime settings,
required binaries, writable paths, database connectivity, global config
state, and systemd service status when available.

Document installer overrides and expose the self-check from the main
navigation.
2026-05-19 16:05:03 +02:00
b93e19a7c8 (refactor) Add native systemd production deployment
Make native systemd services the recommended production path for
pobsync while keeping Docker Compose available for development and
optional test installs.

Add web, worker, and scheduler systemd unit templates, a native
environment example, an optional nginx reverse proxy template, and an
installer that creates the venv, service user, env file, units, and
runs migrations/static collection.

Allow native deployments to configure POBSYNC_BACKUP_ROOT directly and
document the new production layout and update flow.
2026-05-19 15:59:07 +02:00
1297a839d4 (config) Harden Docker deployment for remote servers
Run the Django control panel with Gunicorn instead of the development
runserver and serve static files through WhiteNoise.

Add restart policies, healthchecks, .env-driven production settings, and
a sample .env file for single-server deployments. Update the Docker
entrypoint to collect static assets and document the remote server
deployment and update flow in the README.
2026-05-19 15:33:09 +02:00
c018011e83 (bugfix) Validate Django-managed SSH private keys
Validate uploaded SSH private keys with ssh-keygen before saving them so
invalid, malformed, or unsupported key material is rejected in the
control panel instead of failing later during rsync.

Auto-populate the public key when it is omitted, add an edit flow for
existing SSH credentials, and cover create, update, and invalid-key
paths with view tests.
2026-05-19 15:22:40 +02:00
e65537c6de (feature) Add Django-managed SSH credentials
Add SSH credentials as first-class Django data so backup keys can be
uploaded through the control panel instead of mounted into containers.

Credentials can be selected globally or overridden per host. At runtime
the selected key is materialized inside the container with restrictive
file permissions and injected into the rsync SSH command via IdentityFile.
Known hosts entries are handled the same way when configured.

Add control panel views for creating and listing SSH keys, expose the
fields in config forms and admin, document the workflow, and cover global
and host credential selection with tests.
2026-05-19 14:37:38 +02:00
91ce7ad4c5 (feature) Add host backup control actions
Turn the host detail page into a more useful operator surface for
starting greenfield backups from Django.

Add quick actions for dry-run and real backup runs, keep the advanced
manual options available, and show whether a host is ready, disabled, or
blocked by missing global config. Surface queued and running counts plus
a direct link to the active run.

Expose requested backup options on the run detail page and cover the new
control flow with view tests.
2026-05-19 14:25:28 +02:00
4fb33eca6c (feature) Add Django retention apply flow
Expose retention apply from the host retention plan page so planned
snapshot deletions can be executed from the Django UI.

The form requires explicit host confirmation, carries through the
selected retention kind and base-protection setting, and uses max_delete
as a deletion guard. The view delegates to the SQL retention apply
service and reports predictable pobsync errors back through Django
messages instead of surfacing a server error.

Add view coverage for confirmed deletion, invalid confirmation, and
POST-only enforcement.
2026-05-19 13:54:15 +02:00
83334803b9 (feature) show latest snapshot on the Django dashboard
Add latest snapshot context to each dashboard host row so imported legacy
snapshots are visible without opening every host page.

Link the latest snapshot directly to its detail page and show its kind
and status beside the host snapshot count.

Cover the dashboard latest-snapshot selection with a view test.
2026-05-19 13:44:28 +02:00
5c469f723a edit docker compose file 2026-05-19 13:33:57 +02:00
1d90454109 (feature) improve snapshot discovery visibility in Django
Add a discovery preflight that reports the configured backup root, host
root, and snapshot directory counts before importing anything.

Show discovery status on host detail pages so missing mounts or mismatched
host directories are visible from the UI.

Warn clearly when discovery scans zero snapshots, including whether the
host backup directory is missing or simply empty.
2026-05-19 13:21:31 +02:00
573177e118 (refactor) make Docker backup root static in Django setup
Remove backup_root from the normal Django global config form and display
the fixed container path /backups instead.

Always persist /backups from the setup form so Docker deployments do not
mix host paths with container paths.

Update tests and docs to clarify that the host backup directory is chosen
through the Docker mount, while Django always uses /backups internally.
2026-05-19 13:14:22 +02:00
3da877eb8a (feature) queue manual backups from the Django host page
Add a staff-only manual backup form to host detail pages with safe
dry-run defaults and optional retention settings.

Queue manual BackupRun records through the existing worker-backed runner
path instead of executing backups inside the web request.

Validate disabled hosts, missing global config, and invalid methods with
view tests covering the new UI flow.
2026-05-19 13:04:50 +02:00
fe8e65e12e (feature) add queued backup worker foundation
Move backup execution out of the management command into a reusable
backup runner service that can execute an existing BackupRun record.

Add queue primitives and a run_pobsync_worker command so manual backup
requests can be recorded as queued SQL state and processed outside the
web request path.

Add a worker Docker service and pobsync worker CLI alias, with tests for
queued run creation, worker execution, manual run typing, and command
mapping.
2026-05-19 13:00:12 +02:00
aea22597ba (bugfix) preserve saved global backup root in Django setup form
Fix the global config edit view so default initial values are only used
when creating a new config, preventing saved backup_root values from
being hidden by form defaults.

Keep pobsync_home as an internal runtime setting instead of exposing it
in the normal Django setup form.

Mount a host backup directory into Docker at /backups and document
POBSYNC_BACKUP_ROOT so backup_root behaves predictably in containers.
2026-05-19 12:48:32 +02:00
66e1f549b9 (feature) add Django detail views for backup runs and snapshots
Add staff-only run and snapshot detail pages so scheduler and command
output can be inspected from the Django UI.

Link dashboard and host detail tables to the new detail views, including
snapshot/base relationships and linked backup runs.

Render stored result and metadata JSON in readable form and cover the new
inspection views with tests.
2026-05-19 12:31:47 +02:00
6bcc15c174 (feature) add Django setup flow for initial pobsync configuration
Add staff-only UI routes for creating/editing the default GlobalConfig
and creating the first HostConfig from the dashboard.

Improve the empty dashboard state so a fresh database guides the user
towards the next useful setup action instead of only showing empty tables.

Cover the setup flow with view tests for empty state prompts, global
config creation, and host creation.
2026-05-19 12:25:45 +02:00
4dbde43465 (feature) Add host config editing view
Add a staff-only Django form for editing operational host settings while keeping
host identity stable. Support address, enablement, SSH/source overrides,
include/exclude lists, rsync extra args, and retention settings using the same
SQL-backed HostConfig model consumed by backup and scheduler flows.

Parse newline-separated list fields into JSON lists, preserve nullable
excludes_replace semantics, and cover rendering plus update behavior with view
tests.
2026-05-19 12:17:17 +02:00
6d7bf531ac (feature) Add schedule editing view for hosts
Add a staff-only Django form for creating and updating host schedules using the
SQL-backed ScheduleConfig model. Link the form from host detail pages, validate
cron expressions with the existing scheduler parser, and preserve scheduler/CLI
behavior by writing to the same source of truth.

Cover default rendering, schedule creation, updates, and invalid cron handling
with view tests.
2026-05-19 12:13:12 +02:00
123583a502 (feature) Add read-only retention plan view
Add a staff-only retention plan page for each host using the SQL-backed
retention service. Link it from the host detail page and show policy settings,
keep reasons, and snapshots that would be deleted for scheduled, manual, or all
snapshot kinds.

Keep the flow non-destructive for now, validate query parameters, and cover the
view with tests for rendering, base protection, and invalid kind handling.
2026-05-19 12:00:19 +02:00
3f3bdf2d45 (feature) Add snapshot discovery action to host view
Add a staff-only POST action on host detail pages to discover existing snapshots
for that host and record them into SQL. Show success or failure feedback through
Django messages, and keep the action non-destructive before adding heavier
backup or retention controls.

Cover the action with view tests for successful discovery, redirect behavior,
and method safety.
2026-05-19 11:56:45 +02:00
b0c6afad09 (feature) Add staff-only Django dashboard views
Add a small template-based UI for inspecting pobsync state through Django. The
dashboard shows host, schedule, snapshot, and backup run summaries, while host
detail pages show config, schedule, recent runs, and discovered snapshots.

Keep the views read-only and staff-protected, document the new dashboard URL,
and cover the routes with focused view tests.
2026-05-19 11:53:32 +02:00
2778a589ea (feature) Add staff-only service status API
Add /api/status/ for quick inspection of database backend, object counts, latest
backup run, and latest scheduler activity. Link it from the API index and reuse
schedule serialization between host summaries and status output.

Cover the endpoint with a focused API test and document the new status URL.
2026-05-19 11:46:22 +02:00
ccd89119da (feature) Add staff-only JSON inspection API
Expose lightweight Django JSON endpoints for hosts, snapshots, and backup runs
using the existing admin/staff authentication boundary. Include filters for
snapshot and run inspection, return resolved snapshot base metadata, and document
the new /api/ entrypoint.

Add endpoint tests for authentication, host summaries, snapshot lineage payloads,
and run filtering.
2026-05-19 11:43:50 +02:00
d158644567 Improve Django admin navigation for backup data
Add linked admin summaries for hosts, snapshots, and backup runs so the SQL-first
backup state is easier to inspect from the Django admin. Hosts now link to their
filtered snapshot and run lists, backup runs link back to their snapshot, and
snapshots show base/run relationships without requiring filesystem inspection.

Cover the new admin display helpers with focused tests.
2026-05-19 11:39:10 +02:00
e16c13a1e7 Move Docker web admin port to 8010
Publish the Django web container on host port 8010 while keeping the internal
runserver port at 8000. Update the Docker README URL so the admin location
matches the running compose setup.
2026-05-19 11:34:42 +02:00
797619acd9 Run post-backup pruning through SQL retention
Stop passing prune options into the legacy scheduled backup engine from the
Django backup command. Record the completed snapshot first, then apply retention
through the SQL-backed retention service so pruning sees the same SnapshotRecord
state as the admin and retention command.

Also record prune failures on BackupRun.result instead of leaving the run in an
ambiguous state.
2026-05-19 11:32:32 +02:00
254f915051 Plan Django retention from snapshot records 2026-05-19 11:24:48 +02:00
659377d894 Track snapshot base lineage in Django 2026-05-19 11:19:22 +02:00
5808800981 feat: link backup runs to snapshot records
Add a nullable SnapshotRecord foreign key to BackupRun and populate it
when run_pobsync_backup records a completed or failed snapshot. Keep the
existing snapshot_path for audit compatibility while making run-to-snapshot
navigation explicit in the database and admin.
2026-05-19 11:13:06 +02:00
0a49c5719c feat: record backup snapshots during run completion
Upsert SnapshotRecord rows directly from run_pobsync_backup results so
new successful and failed backup runs are reflected in the database
without requiring a separate discovery pass. Keep discovery for existing
snapshots and repair workflows, and cover success, failure, and dry-run
behavior with tests.
2026-05-19 11:09:20 +02:00
336fb1a5be feat: discover snapshots into Django records
Add a Django-native snapshot discovery service and management command
that scans backup directories, reads snapshot metadata, and idempotently
upserts SnapshotRecord rows. Expose it through the pobsync command
wrapper, update admin/docs, and cover discovery behavior with tests.
2026-05-19 05:18:01 +02:00
e564262c72 refactor: replace legacy CLI with Django command surface
Retire the old YAML and cron oriented pobsync CLI commands and expose a
SQL-first Django-backed command surface instead. Add schedule and
retention management commands, move shared defaults/parsing out of legacy
commands, remove obsolete command modules, and update documentation and
tests for the new workflow.
2026-05-19 05:14:29 +02:00
6d9ddc4457 refactor: stop using legacy JSON for runtime config
Build runtime pobsync configuration exclusively from structured SQL
fields, leaving legacy JSON only for import and audit context. Add
SQL-first management commands for global and host configuration and
cover them with tests.
2026-05-19 05:08:37 +02:00
a0eb5dcc8f refactor: promote backup configuration to structured SQL fields
Add explicit Django model fields for global and host backup settings,
including SSH, rsync, source, excludes, and retention configuration.
Populate them from legacy JSON during migration, make the config
repository prefer structured fields, and update import/admin/tests around
the SQL-first configuration model.
2026-05-19 05:04:49 +02:00
100215bf11 refactor: use injected config sources for retention
Allow retention planning and pruning to use the same ConfigSource
abstraction as scheduled backups. This removes the remaining SQL-to-YAML
export dependency from Django backup runs with pruning, keeping YAML only
as a legacy CLI compatibility path.
2026-05-19 05:00:15 +02:00
bb44f8a09c refactor: inject config sources into scheduled backups
Introduce a ConfigSource interface so scheduled backups no longer need
to load host configuration directly from runtime YAML. Add a Django-backed
config source for SQL-driven backup runs, keep file-based config as the
CLI default, and make scheduled prune execution actually apply retention
after successful runs.
2026-05-19 04:57:10 +02:00
18082496e4 feat: make Django configs drive backups and scheduling
Treat SQL-backed Django models as the source of truth for pobsync
configuration, exporting runtime YAML only as a compatibility layer for
the existing engine. Add a database-driven scheduler command, Docker
scheduler services, schedule run-state fields, and tests for scheduler,
config export, and retention behavior.
2026-05-19 04:53:47 +02:00
1a51c3e448 feat: add Django backend foundation and Docker runtime
Add a Django admin-backed management layer for pobsync configs, runs,
snapshots, and schedules. Keep the existing CLI engine as the execution
source of truth, add import/run management commands, and provide SQLite
default plus optional MariaDB Docker Compose support.
2026-05-19 04:48:13 +02:00
27acd790bd fix an issue with link-dest pointing towards the wrong directory 2026-02-05 11:55:35 +01:00
2fc26df1e5 Update readme.md 2026-02-04 01:26:01 +01:00
1ac326eada remove some installation logic from install.py 2026-02-04 01:01:41 +01:00
7caaf46588 add new deploy script and cutting out pip as the installer 2026-02-03 22:39:13 +01:00
f30d37632b add new install commands so they align better with the doctor code and will set the bin to the correct path 2026-02-03 16:36:18 +01:00
795fe38888 add new doctor commands 2026-02-03 16:29:51 +01:00
4827889f26 feat(schedule): add scheduling commands & clarify create vs update in output 2026-02-03 14:30:55 +01:00
40b6418d7b feat(run-scheduled): add optional post-run pruning 2026-02-03 13:59:17 +01:00
93acc58770 feat(retention): add destructive apply command with safety guards 2026-02-03 12:50:35 +01:00
fdd292b1b6 feat(retention): add optional base protection to plan 2026-02-03 12:42:54 +01:00
1e5807790d feat(retention): show keep/delete details in human output 2026-02-03 12:28:09 +01:00
7a7c4ccaee feat(snapshots): add --tail option to show rsync log 2026-02-03 12:04:53 +01:00
27d7da17b2 feat(snapshots): sort list output by started_at across kinds 2026-02-03 11:58:24 +01:00
9a6d44ca21 feat(snapshots): add list/show commands and snapshot_meta helpers 2026-02-03 11:55:15 +01:00
bc1e2f5662 make meta.yml atomic and add a few things like schema_version, proper base and rsync.command 2026-02-03 11:43:24 +01:00
144 changed files with 20613 additions and 792 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
.git
.venv
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.mypy_cache/
var/
dist/
build/
*.egg-info/

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
POBSYNC_BACKUP_ROOT=/mnt/backups/pobsync
POBSYNC_DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backup.example.com
POBSYNC_DJANGO_SECRET_KEY=change-me-to-a-long-random-secret
POBSYNC_DJANGO_DEBUG=0
POBSYNC_WEB_BIND=127.0.0.1
POBSYNC_GUNICORN_WORKERS=2
POBSYNC_GUNICORN_TIMEOUT=120

12
.gitignore vendored
View File

@@ -1,3 +1,11 @@
__pycache__
*egg-info
__pycache__/
*.py[cod]
.venv/
var/
backups/
.pytest_cache/
.mypy_cache/
*.egg-info/
build/
dist/
.env

88
CHANGELOG.md Normal file
View File

@@ -0,0 +1,88 @@
# Changelog
## 1.2.0 - 2026-05-28
Operations-focused release for more reliable production backups and maintenance.
### Added
- Staff-only updater page for checking configured Gitea releases, inspecting the installed git checkout, fetching tags, pulling the current branch, and running the native systemd updater.
- Read-only control panel access level for authenticated non-staff users, with status pages visible and credentials, logs, configs, retention, and mutating actions kept staff-only.
- Run completion notifications for email and webhooks, including recorded delivery history per run and target.
- Dedicated hosts page with host cards, enabled/disabled filtering, and quick host/schedule/retention state controls.
- Per-host rsync bandwidth limit overrides with inherit, unlimited, and explicit limit semantics.
- Backup data totals by snapshot kind on dashboard and host detail pages, including unique/non-hardlinked data totals.
### Changed
- Real backup runs now default to verbose rsync progress output so the live run view behaves consistently with dry-runs.
- Run progress panels are shared between dry-runs and real runs for more consistent status, timing, cancellation, and log display.
- Incomplete snapshot cleanup now requires operator review before deletion.
- Incomplete snapshot size reporting now prefers on-disk measurement when metadata is stale or missing.
- Installer and environment examples now include optional updater configuration.
### Fixed
- Remote preflight shell commands are now quoted correctly, including roots such as `/`.
- Worker reconciliation now detects real rsync failures and stale/running process state more reliably.
- Retention pruning and incomplete cleanup can delete snapshots containing restrictive directory modes preserved by rsync archive mode.
- Snapshot data summaries no longer count incomplete metadata/log files as backup data when measuring from disk.
- Filesystem SSH credential tests use writable test state without changing production defaults.
## 1.1.0 - 2026-05-21
UI-focused release for the Django control panel.
### Added
- Dedicated list pages for runs, snapshots, schedules, purged snapshots, and changelog navigation.
- Dashboard priority panels for required action, next scheduled work, recent activity, and storage pressure.
- Dashboard host cards with clearer backup activity, snapshot health, next run, and retention status.
- Lightweight live refresh for active run detail pages, including status, timing, controls, and rsync log output.
- Lightweight live refresh for dashboard priority and host status sections.
- Current-page navigation states for primary and system navigation.
- Responsive dashboard behavior for narrower screens.
### Changed
- Reworked the primary navigation around day-to-day operator workflows and moved admin/system links out of the main path.
- Simplified legacy-facing labels and removed source-of-truth wording that no longer applies to the Django-first model.
- Improved run and snapshot detail pages with clearer links between backup runs, snapshots, logs, and review actions.
- Improved dashboard spacing and card layouts to reduce cramped or overlapping text.
- Documented the Django-template-first partial refresh pattern for future UI work.
## 1.0.0 - 2026-05-21
Initial stable release of the Django-first pobsync control panel.
### Added
- Django control panel for hosts, global settings, schedules, SSH credentials, snapshots, runs, self-checks, and logs.
- Native systemd installer and updater for production backup servers.
- SQLite by default, with optional MariaDB support.
- Scheduler and worker services for queued manual backups and scheduled backups.
- Manual backup, dry-run, cancellation, verbose rsync logging, and run detail views.
- Snapshot discovery for existing backup directories and SQL-backed snapshot records.
- SQL retention planning and apply flow with base snapshot protection and incomplete snapshot visibility.
- Explicit cleanup flow for incomplete snapshots, separate from normal retention pruning.
- Purged snapshot audit overview with reason, action source, operator, host, kind, path, and timestamp.
- Dashboard and host pages with backup health, latest run/snapshot, next run, and storage/stat summaries.
- Review resolution for failed/warning runs and incomplete snapshot tasks so operational warnings can be acknowledged.
- Worker heartbeat metadata and stale running-run reconciliation for queued backup workers.
- SSH key generation, upload, edit, guarded delete, known_hosts management, and per-host key selection.
- In-app changelog page sourced from this changelog.
- Restore guidance on snapshot detail pages.
### Changed
- Django and the database are now the source of truth for configuration.
- Docker Compose is documented as development and disposable test tooling rather than the primary production path.
- The `pobsync` console entrypoint is now a maintainer layer around Django management commands.
- Scheduled pruning is evaluated by the pobsync scheduler service and recorded through Django, not host cron.
- Retention and incomplete cleanup now preserve audit history even after source snapshot records are removed.
### Removed
- Legacy YAML config import/export workflow.
- Public short aliases for configuration commands.
- Obsolete global config storage fields.

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends rsync openssh-client cron default-libmysqlclient-dev build-essential pkg-config \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml README.md CHANGELOG.md ./
COPY src ./src
COPY manage.py ./
COPY scripts/docker-entrypoint ./scripts/docker-entrypoint
RUN python -m pip install --upgrade pip \
&& python -m pip install -e ".[mariadb]"
RUN mkdir -p /opt/pobsync/config/hosts /opt/pobsync/state/locks /opt/pobsync/logs /var/lib/pobsync
RUN chmod +x ./scripts/docker-entrypoint
EXPOSE 8000
ENTRYPOINT ["./scripts/docker-entrypoint"]
CMD ["gunicorn", "pobsync_server.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120"]

319
README.md
View File

@@ -1,41 +1,306 @@
# pobsync
`pobsync` is a pull-based backup tool for sysadmins.
It creates rsync-based snapshots with hardlinking (`--link-dest`) and stores them centrally on a backup server.
`pobsync` is a pull-based backup service. It runs on a central backup server and pulls data from remote machines via
rsync over SSH.
Backups are **pulled over SSH**, not pushed, and are designed to be run from cron or manually.
The current refactor is Django-first and SQL-backed:
---
- The Django control panel is the primary interface for setup and operations.
- The database is the source of truth for hosts, schedules, runs, snapshots, credentials, and retention settings.
- SQLite is the default database; MariaDB is optional.
- Backups use the existing rsync snapshot engine internally.
- Scheduling is handled by a Django scheduler service, not host cron.
- SSH keys can be managed from Django and selected globally or per host.
## Design overview
## Recommended Production Install
- Runtime, config, logs and state live under **`/opt/pobsync`**
- Backup data itself is stored under a configurable **`backup_root`** (e.g. `/srv/backups`)
- Two snapshot types:
- **scheduled**
Participates in retention pruning (daily / weekly / monthly / yearly)
- **manual**
Kept outside the scheduled prune chain, defaults to hardlinking from the latest scheduled snapshot
- Minimal dependencies (currently only `PyYAML`)
The recommended production deployment is native systemd services on the backup server. Docker Compose remains available
for development and disposable test installs, but native systemd avoids Docker friction around SSH, filesystem mounts,
large backup storage, and host-level service logs.
---
Recommended layout:
## Requirements
```
/opt/pobsync/app # installed app checkout
/opt/pobsync/venv # Python virtualenv
/etc/pobsync/pobsync.env # settings and secrets
/var/lib/pobsync # SQLite database, state, runtime SSH key files, static files
/backups # backup storage, or set another absolute path
```
- Python **3.11+**
- `rsync`
- `ssh`
- Root or sudo access on the backup server
- SSH keys already configured between backup server and remotes
From a checked-out copy of this repository, run:
---
```
sudo scripts/install-systemd
```
## Installation (system-wide, no venv)
When run from a terminal, the installer asks for the important paths and settings with sensible defaults already filled
in. It can also create the first Django superuser and prints the next steps when installation is complete.
This assumes you are installing as root or via sudo.
The installer will, by default:
From the repository root:
- install required Debian/Ubuntu OS packages with `apt-get`
- copy the checkout to `/opt/pobsync/app`
- create `/opt/pobsync/venv`
- write `/etc/pobsync/pobsync.env` if it does not exist
- install `pobsync-manage`, a Django management wrapper that loads `/etc/pobsync/pobsync.env`
- create `/var/lib/pobsync`, `/var/log/pobsync`, and the backup root
- install Python dependencies
- run migrations and collect static files
- generate a default SSH key for the service user if one does not exist yet
- install and start `pobsync-web`, `pobsync-worker`, and `pobsync-scheduler`
- guide you through the first login and setup steps
```bash
python3 -m pip install --upgrade pip
sudo python3 -m pip install .
Common overrides:
```
sudo scripts/install-systemd \
--backup-root /mnt/backups/pobsync \
--time-zone Europe/Amsterdam \
--allowed-hosts backup.example.com,localhost,127.0.0.1 \
--csrf-trusted-origins https://backup.example.com
```
Use `--no-install-os-packages` if you want to manage system packages yourself. Use `--force-env` only when you want the
installer to rewrite an existing `/etc/pobsync/pobsync.env`.
Use `--non-interactive` for scripted installs. Use `--verbose` when you want to see the underlying apt, pip, Django, and
systemd output.
Schedules are evaluated in `POBSYNC_TIME_ZONE`. The installer defaults this to the server timezone when it can detect
one, otherwise `UTC`; override it with `--time-zone Europe/Amsterdam` or by editing `/etc/pobsync/pobsync.env`.
For MariaDB support, add:
```
sudo scripts/install-systemd --install-extras mariadb
```
## Services
The installer creates:
- `pobsync-web.service`: Gunicorn Django control panel on `127.0.0.1:8010`
- `pobsync-worker.service`: queued backup worker
- `pobsync-scheduler.service`: SQL-backed schedule dispatcher
Check service state and logs:
```
systemctl status pobsync-web pobsync-worker pobsync-scheduler
journalctl -u pobsync-worker -f
```
Restart after configuration changes:
```
sudo systemctl restart pobsync-web pobsync-worker pobsync-scheduler
```
## Reverse Proxy
Use an existing reverse proxy by forwarding to:
```
http://127.0.0.1:8010
```
To install a starter nginx site file:
```
sudo scripts/install-systemd --with-nginx --server-name backup.example.com
```
For HTTPS behind a reverse proxy, set:
```
POBSYNC_DJANGO_ALLOWED_HOSTS=backup.example.com,localhost,127.0.0.1
POBSYNC_DJANGO_CSRF_TRUSTED_ORIGINS=https://backup.example.com
```
## Django UI
After install, open the control panel through your reverse proxy or directly at:
```
http://127.0.0.1:8010/
```
Create a superuser if needed:
```
sudo -u pobsync pobsync-manage createsuperuser
```
The control panel supports two access levels. Django staff users can manage hosts, SSH keys, configs, retention,
notifications, logs, and administrative actions. Normal authenticated users can view backup status pages such as the
dashboard, hosts, runs, snapshots, schedules, purged history, changelog, and `/api/status/`, but cannot see SSH
credentials or run mutating actions.
For other Django management commands on native installs, use `pobsync-manage` so the production environment file is
loaded before Django starts:
```
sudo -u pobsync pobsync-manage showmigrations pobsync_backend
sudo -u pobsync pobsync-manage check
sudo -u pobsync pobsync-manage check_pobsync_install
```
The UI includes:
- dashboard and host detail pages
- global and per-host config forms
- schedule editing
- manual backup queueing
- snapshot discovery
- host checks for backup directories and SSH readiness
- host directory preparation for new or existing hosts
- SQL retention planning and apply flow
- Django-managed SSH keys
- `/self-check/` for runtime checks
- `/logs/` for filtered pobsync service logs
- `/updater/` for checking Gitea releases, pulling the git checkout, and running the native updater
## Bandwidth Limits
Global config can set an rsync bandwidth limit in KB/s. The default `0` means unlimited. Each host can inherit the
global value, set `0` to explicitly run unlimited, or set its own limit for slower remote links.
For VPN-backed or remote backups, start conservatively and adjust after watching normal traffic:
- `2500` KB/s is roughly 20 Mbit/s
- `5000` KB/s is roughly 40 Mbit/s
- `10000` KB/s is roughly 80 Mbit/s
pobsync passes the effective value to rsync as `--bwlimit=<KB/s>` and shows it on the host detail and run detail pages.
## Restoring Data
pobsync treats restores as an explicit manual operation. The control panel shows restore guidance on each snapshot
detail page, but it does not run restore commands for you yet. That is deliberate: restores should be inspected and
tested before data is copied back into a live system.
Each snapshot directory contains:
```
<snapshot>/data/ # backed-up filesystem contents
<snapshot>/meta/ # metadata and rsync logs
```
Use the `data/` directory as the rsync source. Start with a dry run and restore to a staging path first:
```
rsync -aHAX --numeric-ids --info=progress2 --dry-run /backups/example.org/scheduled/<snapshot>/data/ /restore/example.org/
rsync -aHAX --numeric-ids --info=progress2 /backups/example.org/scheduled/<snapshot>/data/ /restore/example.org/
```
After validating the staged files, copy the specific files or directories back to the target machine. For a full-host
restore, use another dry run before writing to the remote root:
```
rsync -aHAX --numeric-ids --info=progress2 --dry-run /backups/example.org/scheduled/<snapshot>/data/ root@example.org:/
```
For most incidents, prefer a targeted restore instead of copying the whole snapshot. Keep paths relative to the
snapshot's `data/` directory:
```
rsync -aHAX --numeric-ids --info=progress2 --dry-run /backups/example.org/scheduled/<snapshot>/data/etc/nginx/ /restore/example.org/etc/nginx/
rsync -aHAX --numeric-ids --info=progress2 --dry-run /backups/example.org/scheduled/<snapshot>/data/home/example/site/public_html/index.php /restore/example.org/home/example/site/public_html/index.php
```
Snapshots may use hardlinks for files that are unchanged between backups. That saves disk space and is safe for normal
restore copies, but do not edit files inside snapshot directories. Treat snapshots as read-only and copy data out with
rsync.
## SSH Keys
SSH keys can be managed from `/ssh-credentials/`. The recommended flow is to generate keys from Django or during the
installer. pobsync stores the private key on disk under the runtime state root (`POBSYNC_HOME`), keeps the public key
visible in the UI, and lets you select a credential either as the global default or as a per-host override.
Generated private keys are stored at:
```
$POBSYNC_HOME/state/ssh-credentials/<id>/identity
```
The key file is written with `0600` permissions and injected into the rsync SSH command with `IdentityFile`. Copy the
public key shown in Django to the target host's `authorized_keys`.
Existing private keys can still be added manually, but generated filesystem keys are preferred for native systemd
production installs.
## Updates
From a fresh checkout or the existing app directory:
```
git pull
sudo scripts/update-systemd
```
The updater is a thin wrapper around the installer for normal production deploys. It preserves the existing
`/etc/pobsync/pobsync.env`, skips OS package installation, skips superuser creation, refreshes the installed app, updates
Python dependencies, runs migrations, collects static files, and restarts the systemd services so new Django code is
loaded.
The Django control panel also exposes an `/updater/` page for staff users. It can check a Gitea releases endpoint, run
`git fetch`, run a fast-forward-only pull for the installed branch, and invoke the configured native update command.
Configure these optional environment variables in `/etc/pobsync/pobsync.env`:
```
POBSYNC_UPDATE_RELEASES_URL=https://code.example.test/api/v1/repos/owner/pobsync/releases
POBSYNC_UPDATE_RELEASES_TOKEN=
POBSYNC_UPDATE_GIT_REMOTE=origin
POBSYNC_UPDATE_COMMAND=sudo -n scripts/update-systemd
```
If the web service runs as the `pobsync` user, `POBSYNC_UPDATE_COMMAND` needs a matching sudoers rule or a different
operator-approved command. Without that, the page still shows update status and command output, but the native update
action will fail with a permission error instead of silently doing the wrong thing.
Use the full installer again when you intentionally want to change install-time settings, install OS packages, enable
nginx, or rewrite the environment file:
```
sudo scripts/install-systemd --non-interactive
sudo scripts/install-systemd --force-env
```
Then check:
```
systemctl status pobsync-web pobsync-worker pobsync-scheduler
sudo -u pobsync pobsync-manage check
sudo -u pobsync pobsync-manage check_pobsync_install
```
Restart services manually after environment or reverse proxy changes:
```
sudo systemctl restart pobsync-web pobsync-worker pobsync-scheduler
```
Inspect service logs with:
```
journalctl -u pobsync-web -n 100 --no-pager
journalctl -u pobsync-worker -f
journalctl -u pobsync-scheduler -n 100 --no-pager
```
Rollback to a previous revision by checking out the known-good commit or tag, then running the updater again:
```
git switch master
git pull
git checkout <known-good-commit-or-tag>
sudo scripts/update-systemd
sudo -u pobsync pobsync-manage check_pobsync_install
```
## Development
Development, Docker, maintainer tooling, and architecture notes live in:
- [docs/development.md](docs/development.md)

24
deploy/bin/pobsync-manage Normal file
View File

@@ -0,0 +1,24 @@
#!/bin/sh
set -eu
APP_DIR="@POBSYNC_APP_DIR@"
VENV_DIR="@POBSYNC_VENV_DIR@"
ENV_FILE="@POBSYNC_ENV_FILE@"
SERVICE_USER="@POBSYNC_USER@"
SERVICE_GROUP="@POBSYNC_GROUP@"
if [ ! -f "$ENV_FILE" ]; then
echo "pobsync environment file not found: $ENV_FILE" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
export POBSYNC_ENV_FILE="$ENV_FILE"
export POBSYNC_SERVICE_USER="$SERVICE_USER"
export POBSYNC_SERVICE_GROUP="$SERVICE_GROUP"
cd "$APP_DIR"
exec "$VENV_DIR/bin/python" "$APP_DIR/manage.py" "$@"

15
deploy/nginx/pobsync.conf Normal file
View File

@@ -0,0 +1,15 @@
server {
listen 80;
server_name @POBSYNC_SERVER_NAME@;
client_max_body_size 16m;
location / {
proxy_pass http://127.0.0.1:8010;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -0,0 +1,26 @@
POBSYNC_DJANGO_DEBUG=0
POBSYNC_DJANGO_SECRET_KEY=change-me-to-a-long-random-secret
POBSYNC_DJANGO_ALLOWED_HOSTS=backup.example.com,localhost,127.0.0.1
POBSYNC_DJANGO_CSRF_TRUSTED_ORIGINS=https://backup.example.com
POBSYNC_HOME=/var/lib/pobsync
POBSYNC_BACKUP_ROOT=/backups
POBSYNC_SQLITE_PATH=/var/lib/pobsync/pobsync.sqlite3
POBSYNC_STATIC_ROOT=/var/lib/pobsync/static
POBSYNC_ENV_FILE=/etc/pobsync/pobsync.env
POBSYNC_SERVICE_USER=pobsync
POBSYNC_SERVICE_GROUP=pobsync
POBSYNC_WEB_BIND=127.0.0.1:8010
POBSYNC_GUNICORN_WORKERS=2
POBSYNC_GUNICORN_TIMEOUT=120
POBSYNC_WORKER_INTERVAL=15
POBSYNC_SCHEDULER_INTERVAL=60
# Optional UI updater integration.
# Point this at the Gitea releases API endpoint, for example:
# https://code.example.test/api/v1/repos/owner/pobsync/releases
POBSYNC_UPDATE_RELEASES_URL=
POBSYNC_UPDATE_RELEASES_TOKEN=
POBSYNC_UPDATE_GIT_REMOTE=origin
POBSYNC_UPDATE_COMMAND=sudo -n scripts/update-systemd

View File

@@ -0,0 +1,20 @@
[Unit]
Description=pobsync schedule dispatcher
After=network-online.target pobsync-web.service
Wants=network-online.target
[Service]
Type=simple
User=@POBSYNC_USER@
Group=@POBSYNC_GROUP@
WorkingDirectory=@POBSYNC_APP_DIR@
EnvironmentFile=@POBSYNC_ENV_FILE@
Environment=POBSYNC_ENV_FILE=@POBSYNC_ENV_FILE@
Environment=POBSYNC_SERVICE_USER=@POBSYNC_USER@
Environment=POBSYNC_SERVICE_GROUP=@POBSYNC_GROUP@
ExecStart=/bin/sh -c 'exec @POBSYNC_VENV_DIR@/bin/python manage.py run_pobsync_scheduler --loop --interval "${POBSYNC_SCHEDULER_INTERVAL:-60}"'
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,22 @@
[Unit]
Description=pobsync Django control panel
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=@POBSYNC_USER@
Group=@POBSYNC_GROUP@
WorkingDirectory=@POBSYNC_APP_DIR@
EnvironmentFile=@POBSYNC_ENV_FILE@
Environment=POBSYNC_ENV_FILE=@POBSYNC_ENV_FILE@
Environment=POBSYNC_SERVICE_USER=@POBSYNC_USER@
Environment=POBSYNC_SERVICE_GROUP=@POBSYNC_GROUP@
ExecStartPre=@POBSYNC_VENV_DIR@/bin/python manage.py migrate --noinput
ExecStartPre=@POBSYNC_VENV_DIR@/bin/python manage.py collectstatic --noinput --clear
ExecStart=/bin/sh -c 'exec @POBSYNC_VENV_DIR@/bin/gunicorn pobsync_server.wsgi:application --bind "${POBSYNC_WEB_BIND:-127.0.0.1:8010}" --workers "${POBSYNC_GUNICORN_WORKERS:-2}" --timeout "${POBSYNC_GUNICORN_TIMEOUT:-120}"'
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,20 @@
[Unit]
Description=pobsync queued backup worker
After=network-online.target pobsync-web.service
Wants=network-online.target
[Service]
Type=simple
User=@POBSYNC_USER@
Group=@POBSYNC_GROUP@
WorkingDirectory=@POBSYNC_APP_DIR@
EnvironmentFile=@POBSYNC_ENV_FILE@
Environment=POBSYNC_ENV_FILE=@POBSYNC_ENV_FILE@
Environment=POBSYNC_SERVICE_USER=@POBSYNC_USER@
Environment=POBSYNC_SERVICE_GROUP=@POBSYNC_GROUP@
ExecStart=/bin/sh -c 'exec @POBSYNC_VENV_DIR@/bin/python manage.py run_pobsync_worker --loop --interval "${POBSYNC_WORKER_INTERVAL:-15}"'
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

167
docker-compose.yml Normal file
View File

@@ -0,0 +1,167 @@
services:
web:
build: .
command: gunicorn pobsync_server.wsgi:application --bind 0.0.0.0:8000 --workers ${POBSYNC_GUNICORN_WORKERS:-2} --timeout ${POBSYNC_GUNICORN_TIMEOUT:-120}
restart: unless-stopped
environment:
POBSYNC_DJANGO_DEBUG: "${POBSYNC_DJANGO_DEBUG:-0}"
POBSYNC_DJANGO_SECRET_KEY: "${POBSYNC_DJANGO_SECRET_KEY:-dev-only-change-me}"
POBSYNC_DJANGO_ALLOWED_HOSTS: "${POBSYNC_DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}"
POBSYNC_HOME: "/opt/pobsync"
POBSYNC_SQLITE_PATH: "/var/lib/pobsync/pobsync.sqlite3"
ports:
- "${POBSYNC_WEB_BIND:-0.0.0.0}:8010:8000"
volumes:
- pobsync_state:/opt/pobsync
- pobsync_db:/var/lib/pobsync
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
interval: 30s
timeout: 10s
retries: 3
scheduler:
build: .
command: python manage.py run_pobsync_scheduler --loop --interval 60
restart: unless-stopped
environment:
POBSYNC_DJANGO_DEBUG: "${POBSYNC_DJANGO_DEBUG:-0}"
POBSYNC_DJANGO_SECRET_KEY: "${POBSYNC_DJANGO_SECRET_KEY:-dev-only-change-me}"
POBSYNC_DJANGO_ALLOWED_HOSTS: "${POBSYNC_DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}"
POBSYNC_HOME: "/opt/pobsync"
POBSYNC_SQLITE_PATH: "/var/lib/pobsync/pobsync.sqlite3"
volumes:
- pobsync_state:/opt/pobsync
- pobsync_db:/var/lib/pobsync
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
interval: 30s
timeout: 10s
retries: 3
worker:
build: .
command: python manage.py run_pobsync_worker --loop --interval 15
restart: unless-stopped
environment:
POBSYNC_DJANGO_DEBUG: "${POBSYNC_DJANGO_DEBUG:-0}"
POBSYNC_DJANGO_SECRET_KEY: "${POBSYNC_DJANGO_SECRET_KEY:-dev-only-change-me}"
POBSYNC_DJANGO_ALLOWED_HOSTS: "${POBSYNC_DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}"
POBSYNC_HOME: "/opt/pobsync"
POBSYNC_SQLITE_PATH: "/var/lib/pobsync/pobsync.sqlite3"
volumes:
- pobsync_state:/opt/pobsync
- pobsync_db:/var/lib/pobsync
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
interval: 30s
timeout: 10s
retries: 3
web-mariadb:
profiles: ["mariadb"]
build: .
command: gunicorn pobsync_server.wsgi:application --bind 0.0.0.0:8000 --workers ${POBSYNC_GUNICORN_WORKERS:-2} --timeout ${POBSYNC_GUNICORN_TIMEOUT:-120}
restart: unless-stopped
environment:
POBSYNC_DJANGO_DEBUG: "${POBSYNC_DJANGO_DEBUG:-0}"
POBSYNC_DJANGO_SECRET_KEY: "${POBSYNC_DJANGO_SECRET_KEY:-dev-only-change-me}"
POBSYNC_DJANGO_ALLOWED_HOSTS: "${POBSYNC_DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}"
POBSYNC_HOME: "/opt/pobsync"
POBSYNC_DB_ENGINE: "mariadb"
POBSYNC_DB_HOST: "db"
POBSYNC_DB_NAME: "pobsync"
POBSYNC_DB_USER: "pobsync"
POBSYNC_DB_PASSWORD: "pobsync"
depends_on:
db:
condition: service_healthy
ports:
- "${POBSYNC_WEB_BIND:-0.0.0.0}:8010:8000"
volumes:
- pobsync_state:/opt/pobsync
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
interval: 30s
timeout: 10s
retries: 3
scheduler-mariadb:
profiles: ["mariadb"]
build: .
command: python manage.py run_pobsync_scheduler --loop --interval 60
restart: unless-stopped
environment:
POBSYNC_DJANGO_DEBUG: "${POBSYNC_DJANGO_DEBUG:-0}"
POBSYNC_DJANGO_SECRET_KEY: "${POBSYNC_DJANGO_SECRET_KEY:-dev-only-change-me}"
POBSYNC_DJANGO_ALLOWED_HOSTS: "${POBSYNC_DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}"
POBSYNC_HOME: "/opt/pobsync"
POBSYNC_DB_ENGINE: "mariadb"
POBSYNC_DB_HOST: "db"
POBSYNC_DB_NAME: "pobsync"
POBSYNC_DB_USER: "pobsync"
POBSYNC_DB_PASSWORD: "pobsync"
depends_on:
db:
condition: service_healthy
volumes:
- pobsync_state:/opt/pobsync
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
interval: 30s
timeout: 10s
retries: 3
worker-mariadb:
profiles: ["mariadb"]
build: .
command: python manage.py run_pobsync_worker --loop --interval 15
restart: unless-stopped
environment:
POBSYNC_DJANGO_DEBUG: "${POBSYNC_DJANGO_DEBUG:-0}"
POBSYNC_DJANGO_SECRET_KEY: "${POBSYNC_DJANGO_SECRET_KEY:-dev-only-change-me}"
POBSYNC_DJANGO_ALLOWED_HOSTS: "${POBSYNC_DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}"
POBSYNC_HOME: "/opt/pobsync"
POBSYNC_DB_ENGINE: "mariadb"
POBSYNC_DB_HOST: "db"
POBSYNC_DB_NAME: "pobsync"
POBSYNC_DB_USER: "pobsync"
POBSYNC_DB_PASSWORD: "pobsync"
depends_on:
db:
condition: service_healthy
volumes:
- pobsync_state:/opt/pobsync
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
interval: 30s
timeout: 10s
retries: 3
db:
profiles: ["mariadb"]
image: mariadb:11
restart: unless-stopped
environment:
MARIADB_DATABASE: "pobsync"
MARIADB_USER: "pobsync"
MARIADB_PASSWORD: "pobsync"
MARIADB_ROOT_PASSWORD: "pobsync-root"
volumes:
- mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 20
volumes:
pobsync_state:
pobsync_db:
mariadb_data:

187
docs/development.md Normal file
View File

@@ -0,0 +1,187 @@
# Development Notes
This document contains development and optional Docker workflows. The recommended production path is the native
systemd installer documented in the README.
## Local Development
```
python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install -e .
mkdir -p var
python3 manage.py migrate
python3 manage.py createsuperuser
python3 manage.py runserver
```
The admin is available at:
- http://127.0.0.1:8000/
- http://127.0.0.1:8000/admin/
Staff-only JSON endpoints are available at:
- http://127.0.0.1:8000/api/
- http://127.0.0.1:8000/api/status/
## Running Tests
The project test suite is currently run through the Docker image so the runtime dependencies match deployment:
```
docker compose build web scheduler worker
docker compose run --rm web python manage.py test pobsync_backend --verbosity 2
```
## Maintainer CLI
The Django UI is the normal operating surface. The `pobsync` entrypoint and direct `manage.py` commands are kept for
debugging, automated maintenance, and migrations. Prefer using the control panel for day-to-day host configuration,
schedule changes, manual backup queueing, snapshot discovery, retention planning, and SSH credential management.
Useful checks:
```
pobsync django check
python3 manage.py showmigrations pobsync_backend
```
The short `pobsync` aliases are limited to operational actions that are useful while debugging a running install.
Configuration aliases are intentionally not public commands; use the Django UI or explicit management commands instead.
## UI Refresh Pattern
The control panel stays Django-template-first. Pages that need live status should expose a small server-rendered partial
view and opt into refresh with `data-refresh-url` and `data-refresh-interval` on the container that should be replaced.
The shared script in `base.html` polls only those explicit regions, skips refreshes while the browser tab is hidden, and
lets the partial response turn polling off with the `X-Pobsync-Refresh-Active: false` header.
Use this for operational status surfaces such as running backup details. Avoid refreshing form-heavy sections while an
operator might be typing.
Worker and scheduler commands are normally run by systemd services:
```
pobsync worker --loop --interval 15
pobsync scheduler --loop --interval 60
```
One-off maintenance commands are still available when the UI is not the right tool:
```
pobsync backup <host> --dry-run
pobsync discover-snapshots --host <host>
pobsync retention <host>
```
For scripted configuration changes, call the Django management command explicitly so it is clear that this is an
automation/debugging path rather than the normal UI workflow:
```
pobsync django configure_pobsync_host <host> --address <host.example>
pobsync django configure_pobsync_schedule <host> --schedule-expression "15 2 * * *"
```
## Installer Development
The native installer is interactive by default when stdin is a terminal. It should keep every prompt backed by a command
line flag or environment variable so production installs remain scriptable.
Useful modes:
```
sudo scripts/install-systemd
sudo scripts/install-systemd --non-interactive
sudo scripts/install-systemd --verbose
sudo scripts/install-systemd --create-superuser --superuser-username admin
sudo scripts/update-systemd
```
The installer should print a short completion summary with the control panel URL, Self Check reminder, and service log
commands. Keep normal output user-facing: pobsync step names with OK, FAILED, or SKIPPED. Full apt, pip, Django, and
systemd output belongs behind `--verbose` or in the failed step output.
The updater is intentionally a small wrapper around the installer for routine production deploys. It should stay
non-interactive, preserve the existing environment file, skip OS package installation, skip superuser creation, and still
run the Django/runtime refresh steps needed after a code update.
## Docker With SQLite
Docker Compose is useful for local development and disposable test installs. Native systemd is preferred for production
backup servers.
```
docker compose up --build web
```
This starts Django on:
- http://127.0.0.1:8010/
- http://127.0.0.1:8010/admin/
- http://127.0.0.1:8010/api/
- http://127.0.0.1:8010/api/status/
Run the scheduler alongside the web admin:
```
docker compose up --build web scheduler worker
```
The web service runs Django through Gunicorn and serves static files with WhiteNoise. The container persists
`/opt/pobsync` and the SQLite database in Docker volumes.
Backup data is always available at `/backups` inside the containers. By default this uses `./backups` on the host.
Override the host-side mount with `POBSYNC_BACKUP_ROOT`:
```
POBSYNC_BACKUP_ROOT=/mnt/backups/pobsync docker compose up --build web scheduler worker
```
## Docker With MariaDB
```
docker compose --profile mariadb up --build web-mariadb
```
With the scheduler:
```
docker compose --profile mariadb up --build web-mariadb scheduler-mariadb worker-mariadb
```
SQLite remains the default because it is enough for a single backup server and keeps deployment simple.
For native systemd installs with MariaDB client support, run the installer with:
```
sudo scripts/install-systemd --install-extras mariadb
```
## Current Architecture
The public operating surface is Django-first. The CLI is now a maintainer layer around Django management commands and
the old YAML/cron workflow has been retired from the `pobsync` entrypoint.
Discovered snapshots are stored in `SnapshotRecord`, including the base snapshot metadata and a nullable SQL link to the
base record when it is known.
The Django retention command plans from `SnapshotRecord` instead of rediscovering snapshots from the filesystem.
Post-backup pruning from Django also uses the SQL retention service after the completed snapshot is recorded.
Staff-only JSON endpoints expose service status, hosts, snapshots, and backup runs for lightweight inspection.
Staff-only dashboard views expose the same operational state through Django templates.
Host pages include a safe snapshot discovery action that records existing snapshots into SQL.
Host pages also include a read-only SQL retention plan view before any destructive pruning action.
Schedules can be created or updated from host pages using the same SQL-backed scheduler model.
Host config can be edited from host pages while keeping host identity stable.
The remaining internal engine code still contains reusable backup primitives:
- snapshot naming and metadata
- rsync command construction and execution
- retention planning and pruning
- host locking
Next refactor targets:
- Move more snapshot lifecycle details into typed domain objects.
- Replace remaining dictionary-shaped config at engine boundaries.

16
manage.py Normal file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env python3
from __future__ import annotations
import os
import sys
def main() -> None:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pobsync_server.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

View File

@@ -4,13 +4,21 @@ build-backend = "setuptools.build_meta"
[project]
name = "pobsync"
version = "0.1.0"
version = "1.2.0"
description = "Pull-based rsync backup tool with hardlinked snapshots"
requires-python = ">=3.11"
dependencies = [
"Django>=5.2,<6.0",
"gunicorn>=23.0,<24.0",
"whitenoise>=6.9,<7.0",
"PyYAML>=6.0"
]
[project.optional-dependencies]
mariadb = [
"mysqlclient>=2.2"
]
[project.scripts]
pobsync = "pobsync.cli:main"

View File

@@ -0,0 +1,9 @@
#!/bin/sh
set -eu
mkdir -p "$(dirname "${POBSYNC_SQLITE_PATH:-/var/lib/pobsync/pobsync.sqlite3}")"
python manage.py migrate --noinput
python manage.py collectstatic --noinput --clear
exec "$@"

598
scripts/install-systemd Executable file
View File

@@ -0,0 +1,598 @@
#!/bin/sh
set -eu
SOURCE_DIR=${POBSYNC_SOURCE_DIR:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)}
APP_DIR=${POBSYNC_APP_DIR:-/opt/pobsync/app}
VENV_DIR=${POBSYNC_VENV_DIR:-/opt/pobsync/venv}
ENV_FILE=${POBSYNC_ENV_FILE:-/etc/pobsync/pobsync.env}
SERVICE_USER=${POBSYNC_SERVICE_USER:-pobsync}
SERVICE_GROUP=${POBSYNC_SERVICE_GROUP:-pobsync}
INSTALL_EXTRAS=${POBSYNC_INSTALL_EXTRAS:-}
SERVER_NAME=${POBSYNC_SERVER_NAME:-_}
ALLOWED_HOSTS=${POBSYNC_ALLOWED_HOSTS:-localhost,127.0.0.1}
CSRF_TRUSTED_ORIGINS=${POBSYNC_CSRF_TRUSTED_ORIGINS:-}
BACKUP_ROOT=${POBSYNC_BACKUP_ROOT:-/backups}
BACKUP_ROOT_EXPLICIT=0
if [ -n "${POBSYNC_BACKUP_ROOT:-}" ]; then
BACKUP_ROOT_EXPLICIT=1
fi
WEB_BIND=${POBSYNC_WEB_BIND:-127.0.0.1:8010}
TIME_ZONE=${POBSYNC_TIME_ZONE:-}
FORCE_ENV=0
INSTALL_OS_PACKAGES=1
WITH_NGINX=0
VERBOSE=0
INTERACTIVE=0
CREATE_SUPERUSER=ask
SUPERUSER_USERNAME=${POBSYNC_SUPERUSER_USERNAME:-}
SUPERUSER_EMAIL=${POBSYNC_SUPERUSER_EMAIL:-}
SUPERUSER_PASSWORD=${POBSYNC_SUPERUSER_PASSWORD:-}
if [ -t 0 ]; then
INTERACTIVE=1
fi
while [ "$#" -gt 0 ]; do
case "$1" in
--source-dir)
SOURCE_DIR=$2
shift 2
;;
--app-dir)
APP_DIR=$2
shift 2
;;
--venv-dir)
VENV_DIR=$2
shift 2
;;
--env-file)
ENV_FILE=$2
shift 2
;;
--service-user)
SERVICE_USER=$2
shift 2
;;
--service-group)
SERVICE_GROUP=$2
shift 2
;;
--backup-root)
BACKUP_ROOT=$2
BACKUP_ROOT_EXPLICIT=1
shift 2
;;
--allowed-hosts)
ALLOWED_HOSTS=$2
shift 2
;;
--csrf-trusted-origins)
CSRF_TRUSTED_ORIGINS=$2
shift 2
;;
--web-bind)
WEB_BIND=$2
shift 2
;;
--time-zone)
TIME_ZONE=$2
shift 2
;;
--force-env)
FORCE_ENV=1
shift
;;
--verbose)
VERBOSE=1
shift
;;
--interactive)
INTERACTIVE=1
shift
;;
--non-interactive)
INTERACTIVE=0
shift
;;
--no-install-os-packages)
INSTALL_OS_PACKAGES=0
shift
;;
--install-extras)
INSTALL_EXTRAS=$2
shift 2
;;
--with-nginx)
WITH_NGINX=1
shift
;;
--server-name)
SERVER_NAME=$2
shift 2
;;
--create-superuser)
CREATE_SUPERUSER=1
shift
;;
--no-create-superuser)
CREATE_SUPERUSER=0
shift
;;
--superuser-username)
SUPERUSER_USERNAME=$2
shift 2
;;
--superuser-email)
SUPERUSER_EMAIL=$2
shift 2
;;
--superuser-password)
SUPERUSER_PASSWORD=$2
shift 2
;;
*)
echo "Unknown argument: $1" >&2
exit 2
;;
esac
done
if [ "$(id -u)" -ne 0 ]; then
echo "Run this installer as root." >&2
exit 1
fi
if [ -f "$ENV_FILE" ] && [ "$FORCE_ENV" -ne 1 ] && [ "$BACKUP_ROOT_EXPLICIT" -ne 1 ]; then
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
if [ -n "${POBSYNC_BACKUP_ROOT:-}" ]; then
BACKUP_ROOT=$POBSYNC_BACKUP_ROOT
fi
fi
detect_time_zone() {
if [ -n "$TIME_ZONE" ]; then
printf '%s\n' "$TIME_ZONE"
return
fi
if [ -n "${POBSYNC_TIME_ZONE:-}" ]; then
printf '%s\n' "$POBSYNC_TIME_ZONE"
return
fi
if command -v timedatectl >/dev/null 2>&1; then
detected=$(timedatectl show -p Timezone --value 2>/dev/null || true)
if [ -n "$detected" ]; then
printf '%s\n' "$detected"
return
fi
fi
if [ -f /etc/timezone ]; then
detected=$(sed -n '1p' /etc/timezone | tr -d '[:space:]')
if [ -n "$detected" ]; then
printf '%s\n' "$detected"
return
fi
fi
printf 'UTC\n'
}
TIME_ZONE=$(detect_time_zone)
run_step() {
label=$1
shift
if [ "$VERBOSE" -eq 1 ]; then
echo "==> $label"
"$@"
echo "OK: $label"
return
fi
printf '%-48s' "$label"
log_file=$(mktemp)
if "$@" >"$log_file" 2>&1; then
rm -f "$log_file"
echo "OK"
return
fi
echo "FAILED"
echo
echo "Output from failed step '$label':" >&2
cat "$log_file" >&2
rm -f "$log_file"
exit 1
}
note_step() {
label=$1
status=$2
printf '%-48s%s\n' "$label" "$status"
}
prompt_value() {
prompt=$1
default=$2
if [ "$INTERACTIVE" -ne 1 ]; then
printf '%s\n' "$default"
return
fi
printf '%s [%s]: ' "$prompt" "$default" >&2
read -r answer
if [ -n "$answer" ]; then
printf '%s\n' "$answer"
else
printf '%s\n' "$default"
fi
}
prompt_yes_no() {
prompt=$1
default=$2
if [ "$INTERACTIVE" -ne 1 ]; then
printf '%s\n' "$default"
return
fi
if [ "$default" -eq 1 ]; then
suffix=Y/n
else
suffix=y/N
fi
while :; do
printf '%s [%s]: ' "$prompt" "$suffix" >&2
read -r answer
case "$answer" in
"")
printf '%s\n' "$default"
return
;;
y|Y|yes|YES|Yes)
printf '1\n'
return
;;
n|N|no|NO|No)
printf '0\n'
return
;;
esac
done
}
prompt_secret() {
prompt=$1
if [ "$INTERACTIVE" -ne 1 ]; then
printf '\n'
return
fi
printf '%s: ' "$prompt" >&2
stty -echo
read -r secret
stty echo
printf '\n' >&2
printf '%s\n' "$secret"
}
if [ "$INTERACTIVE" -eq 1 ]; then
echo "pobsync native installer"
echo
echo "Press Enter to accept defaults. Existing command-line flags are already applied as defaults."
echo
SOURCE_DIR=$(prompt_value "Source checkout" "$SOURCE_DIR")
APP_DIR=$(prompt_value "Install app directory" "$APP_DIR")
VENV_DIR=$(prompt_value "Python virtualenv directory" "$VENV_DIR")
ENV_FILE=$(prompt_value "Environment file" "$ENV_FILE")
SERVICE_USER=$(prompt_value "Service user" "$SERVICE_USER")
SERVICE_GROUP=$(prompt_value "Service group" "$SERVICE_GROUP")
BACKUP_ROOT=$(prompt_value "Backup storage path" "$BACKUP_ROOT")
WEB_BIND=$(prompt_value "Gunicorn bind address" "$WEB_BIND")
TIME_ZONE=$(prompt_value "Scheduler time zone" "$TIME_ZONE")
ALLOWED_HOSTS=$(prompt_value "Allowed hosts" "$ALLOWED_HOSTS")
CSRF_TRUSTED_ORIGINS=$(prompt_value "CSRF trusted origins, comma-separated or blank" "$CSRF_TRUSTED_ORIGINS")
INSTALL_OS_PACKAGES=$(prompt_yes_no "Install required OS packages with apt-get" "$INSTALL_OS_PACKAGES")
use_mariadb=0
if [ "$INSTALL_EXTRAS" = "mariadb" ] || [ "$INSTALL_EXTRAS" = "[mariadb]" ] || [ "$INSTALL_EXTRAS" = ".[mariadb]" ]; then
use_mariadb=1
fi
use_mariadb=$(prompt_yes_no "Install MariaDB Python/client support" "$use_mariadb")
if [ "$use_mariadb" -eq 1 ]; then
INSTALL_EXTRAS=mariadb
else
INSTALL_EXTRAS=
fi
WITH_NGINX=$(prompt_yes_no "Install starter nginx reverse proxy config" "$WITH_NGINX")
if [ "$WITH_NGINX" -eq 1 ]; then
SERVER_NAME=$(prompt_value "Nginx server_name" "$SERVER_NAME")
fi
if [ "$CREATE_SUPERUSER" = "ask" ]; then
CREATE_SUPERUSER=$(prompt_yes_no "Create first Django superuser after install" 1)
fi
if [ "$CREATE_SUPERUSER" -eq 1 ]; then
SUPERUSER_USERNAME=$(prompt_value "Superuser username" "${SUPERUSER_USERNAME:-admin}")
SUPERUSER_EMAIL=$(prompt_value "Superuser email, blank allowed" "$SUPERUSER_EMAIL")
if [ -z "$SUPERUSER_PASSWORD" ]; then
SUPERUSER_PASSWORD=$(prompt_secret "Superuser password, leave blank to run createsuperuser interactively later")
fi
fi
echo
fi
if [ "$CREATE_SUPERUSER" = "ask" ]; then
if [ -n "$SUPERUSER_USERNAME" ] && [ -n "$SUPERUSER_PASSWORD" ]; then
CREATE_SUPERUSER=1
else
CREATE_SUPERUSER=0
fi
fi
install_os_packages() {
if [ "$INSTALL_OS_PACKAGES" -ne 1 ]; then
note_step "Install OS packages" "SKIPPED"
return
fi
if command -v apt-get >/dev/null 2>&1; then
packages="python3 python3-venv python3-pip rsync openssh-client"
if [ "$WITH_NGINX" -eq 1 ]; then
packages="$packages nginx"
fi
if [ "$INSTALL_EXTRAS" = "mariadb" ] || [ "$INSTALL_EXTRAS" = "[mariadb]" ] || [ "$INSTALL_EXTRAS" = ".[mariadb]" ]; then
packages="$packages default-libmysqlclient-dev build-essential pkg-config"
fi
run_step "Install OS packages" sh -c "apt-get update && apt-get install -y --no-install-recommends $packages"
return
fi
echo "No supported package manager found; install python3, python3-venv, rsync, and openssh-client manually." >&2
}
install_os_packages
if ! command -v python3 >/dev/null 2>&1; then
echo "python3 is required." >&2
exit 1
fi
if ! env POBSYNC_INSTALL_TIME_ZONE="$TIME_ZONE" python3 -c "import os; from zoneinfo import ZoneInfo; ZoneInfo(os.environ['POBSYNC_INSTALL_TIME_ZONE'])" >/dev/null 2>&1; then
echo "Invalid time zone: $TIME_ZONE" >&2
echo "Use an IANA timezone such as UTC or Europe/Amsterdam." >&2
exit 1
fi
if ! command -v rsync >/dev/null 2>&1; then
echo "rsync is required." >&2
exit 1
fi
if ! command -v ssh >/dev/null 2>&1; then
echo "openssh-client is required." >&2
exit 1
fi
if [ ! -f "$SOURCE_DIR/manage.py" ]; then
echo "Source directory does not look like a pobsync checkout: $SOURCE_DIR" >&2
exit 1
fi
if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then
run_step "Create service group" groupadd --system "$SERVICE_GROUP"
else
note_step "Create service group" "OK"
fi
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
run_step "Create service user" useradd --system --home /var/lib/pobsync --shell /usr/sbin/nologin --gid "$SERVICE_GROUP" "$SERVICE_USER"
else
note_step "Create service user" "OK"
fi
grant_journal_access() {
for group in systemd-journal adm; do
if getent group "$group" >/dev/null 2>&1; then
usermod -a -G "$group" "$SERVICE_USER"
fi
done
}
run_step "Grant journal access" grant_journal_access
run_step "Prepare directories" mkdir -p /etc/pobsync /var/lib/pobsync /var/log/pobsync "$(dirname "$VENV_DIR")" "$APP_DIR" "$BACKUP_ROOT"
run_step "Set state directory permissions" chown "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync "$BACKUP_ROOT"
run_step "Set private directory modes" chmod 0750 /var/lib/pobsync /var/log/pobsync "$BACKUP_ROOT"
if [ "$SOURCE_DIR" != "$APP_DIR" ]; then
run_step "Sync application files" rsync -a --delete \
--exclude .git \
--exclude .venv \
--exclude __pycache__ \
--exclude .pytest_cache \
--exclude .mypy_cache \
--exclude var \
"$SOURCE_DIR"/ "$APP_DIR"/
else
note_step "Sync application files" "SKIPPED"
fi
run_step "Create Python virtualenv" python3 -m venv "$VENV_DIR"
run_step "Upgrade pip" "$VENV_DIR/bin/python" -m pip install --upgrade pip
case "$INSTALL_EXTRAS" in
"")
pip_target=$APP_DIR
;;
mariadb)
pip_target="$APP_DIR[mariadb]"
;;
\[*\])
pip_target="$APP_DIR$INSTALL_EXTRAS"
;;
.\[*\])
pip_target="$APP_DIR${INSTALL_EXTRAS#.}"
;;
*)
echo "Unsupported install extras: $INSTALL_EXTRAS" >&2
exit 2
;;
esac
run_step "Install Python package" "$VENV_DIR/bin/python" -m pip install -e "$pip_target"
if [ ! -f "$ENV_FILE" ] || [ "$FORCE_ENV" -eq 1 ]; then
secret=$("$VENV_DIR/bin/python" -c "import secrets; print(secrets.token_urlsafe(48))")
cat > "$ENV_FILE" <<EOF
POBSYNC_DJANGO_DEBUG=0
POBSYNC_DJANGO_SECRET_KEY=$secret
POBSYNC_DJANGO_ALLOWED_HOSTS=$ALLOWED_HOSTS
POBSYNC_DJANGO_CSRF_TRUSTED_ORIGINS=$CSRF_TRUSTED_ORIGINS
POBSYNC_HOME=/var/lib/pobsync
POBSYNC_BACKUP_ROOT=$BACKUP_ROOT
POBSYNC_TIME_ZONE=$TIME_ZONE
POBSYNC_SQLITE_PATH=/var/lib/pobsync/pobsync.sqlite3
POBSYNC_STATIC_ROOT=/var/lib/pobsync/static
POBSYNC_ENV_FILE=$ENV_FILE
POBSYNC_SERVICE_USER=$SERVICE_USER
POBSYNC_SERVICE_GROUP=$SERVICE_GROUP
POBSYNC_WEB_BIND=$WEB_BIND
POBSYNC_GUNICORN_WORKERS=2
POBSYNC_GUNICORN_TIMEOUT=120
POBSYNC_WORKER_INTERVAL=15
POBSYNC_SCHEDULER_INTERVAL=60
POBSYNC_UPDATE_RELEASES_URL=
POBSYNC_UPDATE_RELEASES_TOKEN=
POBSYNC_UPDATE_GIT_REMOTE=origin
POBSYNC_UPDATE_COMMAND=sudo -n scripts/update-systemd
EOF
chmod 0640 "$ENV_FILE"
chown "root:$SERVICE_GROUP" "$ENV_FILE"
note_step "Write environment file" "OK"
else
note_step "Write environment file" "SKIPPED"
echo "Keeping existing $ENV_FILE. Use --force-env to rewrite it."
fi
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
install_unit() {
src=$1
dest=$2
sed \
-e "s|@POBSYNC_APP_DIR@|$APP_DIR|g" \
-e "s|@POBSYNC_VENV_DIR@|$VENV_DIR|g" \
-e "s|@POBSYNC_ENV_FILE@|$ENV_FILE|g" \
-e "s|@POBSYNC_USER@|$SERVICE_USER|g" \
-e "s|@POBSYNC_GROUP@|$SERVICE_GROUP|g" \
"$src" > "$dest"
chmod 0644 "$dest"
}
install_units() {
install_unit "$APP_DIR/deploy/systemd/pobsync-web.service" /etc/systemd/system/pobsync-web.service
install_unit "$APP_DIR/deploy/systemd/pobsync-worker.service" /etc/systemd/system/pobsync-worker.service
install_unit "$APP_DIR/deploy/systemd/pobsync-scheduler.service" /etc/systemd/system/pobsync-scheduler.service
}
run_step "Install systemd units" install_units
install_manage_wrapper() {
sed \
-e "s|@POBSYNC_APP_DIR@|$APP_DIR|g" \
-e "s|@POBSYNC_VENV_DIR@|$VENV_DIR|g" \
-e "s|@POBSYNC_ENV_FILE@|$ENV_FILE|g" \
-e "s|@POBSYNC_USER@|$SERVICE_USER|g" \
-e "s|@POBSYNC_GROUP@|$SERVICE_GROUP|g" \
"$APP_DIR/deploy/bin/pobsync-manage" > /usr/local/bin/pobsync-manage
chmod 0755 /usr/local/bin/pobsync-manage
}
run_step "Install manage wrapper" install_manage_wrapper
run_step "Reload systemd" systemctl daemon-reload
run_step "Run database migrations" /usr/local/bin/pobsync-manage migrate --noinput
run_step "Ensure default SSH key" /usr/local/bin/pobsync-manage ensure_pobsync_ssh_key --name default --set-global-default
run_step "Collect static files" /usr/local/bin/pobsync-manage collectstatic --noinput --clear
run_step "Finalize state permissions" chown -R "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync
superuser_exists=$("$VENV_DIR/bin/python" -c "import os; os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pobsync_server.settings'); import django; django.setup(); from django.contrib.auth import get_user_model; print('yes' if get_user_model().objects.filter(is_superuser=True).exists() else 'no')")
if [ "$CREATE_SUPERUSER" -eq 1 ]; then
if [ "$superuser_exists" = "yes" ]; then
note_step "Create Django superuser" "SKIPPED"
elif [ -n "$SUPERUSER_USERNAME" ] && [ -n "$SUPERUSER_PASSWORD" ]; then
run_step "Create Django superuser" env \
DJANGO_SUPERUSER_USERNAME="$SUPERUSER_USERNAME" \
DJANGO_SUPERUSER_EMAIL="$SUPERUSER_EMAIL" \
DJANGO_SUPERUSER_PASSWORD="$SUPERUSER_PASSWORD" \
/usr/local/bin/pobsync-manage createsuperuser --noinput
run_step "Finalize superuser permissions" chown -R "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync
else
note_step "Create Django superuser" "SKIPPED"
echo "No superuser password was provided; create one later with:"
echo " sudo -u $SERVICE_USER pobsync-manage createsuperuser"
fi
elif [ "$superuser_exists" != "yes" ]; then
note_step "Create Django superuser" "SKIPPED"
echo "No Django superuser exists yet. Create one with:"
echo " sudo -u $SERVICE_USER pobsync-manage createsuperuser"
else
note_step "Create Django superuser" "SKIPPED"
fi
run_step "Enable services" systemctl enable pobsync-web.service pobsync-worker.service pobsync-scheduler.service
run_step "Restart services" systemctl restart pobsync-web.service pobsync-worker.service pobsync-scheduler.service
if [ "$WITH_NGINX" -eq 1 ]; then
if ! command -v nginx >/dev/null 2>&1; then
note_step "Install nginx config" "SKIPPED"
echo "nginx is not installed; skipping nginx config." >&2
else
sed "s|@POBSYNC_SERVER_NAME@|$SERVER_NAME|g" "$APP_DIR/deploy/nginx/pobsync.conf" > /etc/nginx/sites-available/pobsync.conf
ln -sf /etc/nginx/sites-available/pobsync.conf /etc/nginx/sites-enabled/pobsync.conf
note_step "Install nginx config" "OK"
run_step "Validate nginx config" nginx -t
run_step "Reload nginx" systemctl reload nginx
fi
else
note_step "Install nginx config" "SKIPPED"
fi
if [ "$VERBOSE" -eq 1 ]; then
systemctl --no-pager --full status pobsync-web.service pobsync-worker.service pobsync-scheduler.service || true
fi
echo
echo "pobsync installation complete."
echo
echo "Open the Django control panel at:"
echo " http://$WEB_BIND/"
echo
echo "If pobsync is behind a reverse proxy, use your public hostname instead."
echo
echo "Recommended first steps:"
echo " 1. Log in to the Django control panel."
echo " 2. Open Self Check and resolve any warnings."
echo " 3. Configure global settings and backup storage."
echo " 4. Add an SSH key under SSH Keys."
echo " 5. Add a host and queue a dry-run backup."
echo
echo "Useful commands:"
echo " systemctl status pobsync-web pobsync-worker pobsync-scheduler"
echo " journalctl -u pobsync-worker -f"
echo " sudo -u $SERVICE_USER pobsync-manage check"
echo " sudo -u $SERVICE_USER pobsync-manage check_pobsync_install"

41
scripts/update-systemd Executable file
View File

@@ -0,0 +1,41 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
show_help() {
cat <<'EOF'
Usage: sudo scripts/update-systemd [options]
Refresh an existing native pobsync systemd install from the current checkout.
This is a thin, safer update wrapper around scripts/install-systemd. It keeps
the install non-interactive, preserves the existing environment file, skips
superuser creation, and skips OS package installation by default.
Common options are forwarded to install-systemd, for example:
--source-dir PATH
--app-dir PATH
--venv-dir PATH
--env-file PATH
--service-user USER
--service-group GROUP
--install-extras mariadb
--verbose
If OS packages need to be refreshed, run scripts/install-systemd directly.
EOF
}
case "${1:-}" in
-h|--help)
show_help
exit 0
;;
esac
exec "$SCRIPT_DIR/install-systemd" \
--non-interactive \
--no-install-os-packages \
--no-create-superuser \
"$@"

View File

@@ -1,3 +1,2 @@
__all__ = ["__version__"]
__version__ = "0.1.0"
__version__ = "1.2.0"

View File

@@ -1,265 +1,66 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
import os
import sys
from typing import Sequence
from .commands.doctor import run_doctor
from .commands.init_host import run_init_host
from .commands.install import run_install
from .commands.list_remotes import run_list_remotes
from .commands.show_config import run_show_config, dump_yaml
from .commands.run_scheduled import run_scheduled
from .errors import ConfigError, DoctorError, InstallError, PobsyncError, LockError
from .paths import PobsyncPaths
from .util import is_tty, to_json_safe
from django.core.management import execute_from_command_line
from pobsync import __version__
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="pobsync")
p.add_argument("--prefix", default="/opt/pobsync", help="Pobsync home directory (default: /opt/pobsync)")
p.add_argument("--json", action="store_true", help="Machine-readable JSON output")
sub = p.add_subparsers(dest="command", required=True)
# install
ip = sub.add_parser("install", help="Bootstrap /opt/pobsync layout and create global config")
ip.add_argument("--backup-root", help="Backup root directory (e.g. /srv/backups)")
ip.add_argument("--retention", default="daily=14,weekly=8,monthly=12,yearly=0", help="Default retention for init-host")
ip.add_argument("--force", action="store_true", help="Overwrite existing global config")
ip.add_argument("--dry-run", action="store_true", help="Show actions, do not write")
ip.set_defaults(_handler=cmd_install)
# init-host
hp = sub.add_parser("init-host", help="Create a host config YAML under config/hosts")
hp.add_argument("host", help="Host name (used as filename)")
hp.add_argument("--address", help="Hostname or IP of the remote")
hp.add_argument("--ssh-user", default=None)
hp.add_argument("--ssh-port", type=int, default=None)
hp.add_argument("--retention", default=None, help="Override retention for this host (daily=...,weekly=...)")
hp.add_argument("--exclude-add", action="append", default=[], help="Additional excludes (repeatable)")
hp.add_argument("--exclude-replace", action="append", default=None, help="Replace excludes list (repeatable)")
hp.add_argument("--include", action="append", default=[], help="Include patterns (repeatable)")
hp.add_argument("--force", action="store_true")
hp.add_argument("--dry-run", action="store_true")
hp.set_defaults(_handler=cmd_init_host)
# doctor
dp = sub.add_parser("doctor", help="Validate installation and configuration")
dp.add_argument("host", nargs="?", default=None, help="Optional host to validate")
dp.add_argument("--connect", action="store_true", help="Try SSH connectivity check (phase 2)")
dp.add_argument("--rsync-dry-run", action="store_true", help="Try rsync dry run (phase 2)")
dp.set_defaults(_handler=cmd_doctor)
# list remotes
lp = sub.add_parser("list-remotes", help="List configured remotes (host configs)")
lp.set_defaults(_handler=cmd_list_remotes)
# show config
sp = sub.add_parser("show-config", help="Show host configuration (raw or effective)")
sp.add_argument("host", help="Host to show")
sp.add_argument("--effective", action="store_true", help="Show merged effective config")
sp.set_defaults(_handler=cmd_show_config)
# run scheduled
rp = sub.add_parser("run-scheduled", help="Run a scheduled backup for a host")
rp.add_argument("host", help="Host to back up")
rp.add_argument("--dry-run", action="store_true", help="Run rsync --dry-run without creating directories")
rp.set_defaults(_handler=cmd_run_scheduled)
COMMAND_ALIASES = {
"backup": "run_pobsync_backup",
"retention": "run_pobsync_retention",
"discover-snapshots": "discover_pobsync_snapshots",
"scheduler": "run_pobsync_scheduler",
"worker": "run_pobsync_worker",
}
return p
def _usage() -> str:
commands = "\n".join(f" {name}" for name in sorted(COMMAND_ALIASES))
return f"""pobsync is now backed by Django management commands.
Usage:
pobsync <command> [options]
pobsync django <management-command> [options]
Commands:
{commands}
Configuration is managed from the Django control panel. Use
`pobsync django <management-command>` for automation or debugging.
"""
def parse_retention(s: str) -> dict[str, int]:
"""
Parse format: daily=14,weekly=8,monthly=12,yearly=0
"""
out: dict[str, int] = {}
parts = [p.strip() for p in s.split(",") if p.strip()]
for part in parts:
if "=" not in part:
raise ValueError(f"Invalid retention component: {part!r}")
k, v = part.split("=", 1)
k = k.strip()
v = v.strip()
if k not in {"daily", "weekly", "monthly", "yearly"}:
raise ValueError(f"Invalid retention key: {k!r}")
n = int(v)
if n < 0:
raise ValueError(f"Retention must be >= 0 for {k}")
out[k] = n
# Ensure all keys exist (default missing to 0)
for k in ("daily", "weekly", "monthly", "yearly"):
out.setdefault(k, 0)
return out
def main(argv: Sequence[str] | None = None) -> int:
args = list(sys.argv[1:] if argv is None else argv)
if args and args[0] in {"--version", "version"}:
print(f"pobsync {__version__}")
return 0
if not args or args[0] in {"-h", "--help", "help"}:
print(_usage())
return 0
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pobsync_server.settings")
def _print(result: dict[str, Any], as_json: bool) -> None:
if as_json:
print(json.dumps(to_json_safe(result), indent=2, sort_keys=False))
return
# Minimal human output
if result.get("ok") is True:
print("OK")
command = args[0]
if command == "django":
django_args = ["pobsync", *args[1:]]
else:
print("FAILED")
# Standard action list
if "actions" in result:
for a in result["actions"]:
print(f"- {a}")
# Single action (e.g. init-host)
if "action" in result:
print(f"- {result['action']}")
# Doctor-style results list
if "results" in result:
for r in result["results"]:
ok = r.get("ok", False)
label = "OK" if ok else "FAIL"
name = r.get("check", "check")
msg = r.get("message") or r.get("error") or ""
extra = ""
if "path" in r:
extra = f" ({r['path']})"
elif "name" in r:
extra = f" ({r['name']})"
elif "host" in r:
extra = f" ({r['host']})"
line = f"- {label} {name}{extra}"
if msg:
line += f" {msg}"
print(line)
# list-remotes style output
if "hosts" in result:
for h in result["hosts"]:
print(h)
if "snapshot" in result:
print(f"- snapshot {result['snapshot']}")
if "base" in result and result["base"]:
print(f"- base {result['base']}")
def cmd_install(args: argparse.Namespace) -> int:
prefix = Path(args.prefix)
retention = parse_retention(args.retention)
backup_root = args.backup_root
if backup_root is None and is_tty():
backup_root = input("backup_root (absolute path, not '/'): ").strip() or None
result = run_install(
prefix=prefix,
backup_root=backup_root,
retention=retention,
dry_run=bool(args.dry_run),
force=bool(args.force),
)
_print(result, as_json=bool(args.json))
return 0 if result.get("ok") else 1
def cmd_init_host(args: argparse.Namespace) -> int:
prefix = Path(args.prefix)
address = args.address
if address is None and is_tty():
address = input("address (hostname or ip): ").strip() or None
if not address:
raise ConfigError("--address is required (or interactive input)")
if args.retention is None:
# In phase 1 we require retention explicitly or via install default.
# We'll read global.yaml if present to fetch retention_defaults.
from .config.load import load_global_config
paths = PobsyncPaths(home=prefix)
global_cfg = load_global_config(paths.global_config_path)
retention = global_cfg.get("retention_defaults") or {"daily": 14, "weekly": 8, "monthly": 12, "yearly": 0}
else:
retention = parse_retention(args.retention)
excludes_replace = args.exclude_replace if args.exclude_replace is not None else None
result = run_init_host(
prefix=prefix,
host=args.host,
address=address,
retention=retention,
ssh_user=args.ssh_user,
ssh_port=args.ssh_port,
excludes_add=list(args.exclude_add),
excludes_replace=excludes_replace,
includes=list(args.include),
dry_run=bool(args.dry_run),
force=bool(args.force),
)
_print(result, as_json=bool(args.json))
return 0 if result.get("ok") else 1
def cmd_show_config(args: argparse.Namespace) -> int:
prefix = Path(args.prefix)
result = run_show_config(prefix=prefix, host=args.host, effective=bool(args.effective))
if args.json:
_print(result, as_json=True)
else:
print(dump_yaml(result["config"]).rstrip())
return 0 if result.get("ok") else 1
def cmd_doctor(args: argparse.Namespace) -> int:
prefix = Path(args.prefix)
result = run_doctor(prefix=prefix, host=args.host, connect=bool(args.connect), rsync_dry_run=bool(args.rsync_dry_run))
_print(result, as_json=bool(args.json))
return 0 if result.get("ok") else 1
def cmd_list_remotes(args: argparse.Namespace) -> int:
prefix = Path(args.prefix)
result = run_list_remotes(prefix=prefix)
_print(result, as_json=bool(args.json))
return 0 if result.get("ok") else 1
def cmd_run_scheduled(args: argparse.Namespace) -> int:
prefix = Path(args.prefix)
result = run_scheduled(prefix=prefix, host=args.host, dry_run=bool(args.dry_run))
_print(result, as_json=bool(args.json))
return 0 if result.get("ok") else 2
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
mapped = COMMAND_ALIASES.get(command)
if mapped is None:
print(f"Unknown pobsync command: {command}", file=sys.stderr)
print(_usage(), file=sys.stderr)
return 2
django_args = ["pobsync", mapped, *args[1:]]
try:
handler = getattr(args, "_handler")
return int(handler(args))
except PobsyncError as e:
if args.json:
_print({"ok": False, "error": str(e), "type": type(e).__name__}, as_json=True)
else:
print(f"ERROR: {e}")
if isinstance(e, LockError):
return 10
execute_from_command_line(django_args)
except SystemExit as exc:
code = exc.code
if isinstance(code, int):
return code
return 1
except KeyboardInterrupt:
if args.json:
_print({"ok": False, "error": "interrupted"}, as_json=True)
else:
print("ERROR: interrupted")
return 130
return 0

View File

@@ -1,111 +0,0 @@
from __future__ import annotations
import os
import shutil
from pathlib import Path
from typing import Any
from ..config.load import load_global_config, load_host_config
from ..errors import DoctorError
from ..paths import PobsyncPaths
from ..util import is_absolute_non_root
def _check_binary(name: str) -> tuple[bool, str]:
p = shutil.which(name)
if not p:
return False, f"missing binary: {name}"
return True, f"ok: {name} -> {p}"
def _check_writable_dir(path: Path) -> tuple[bool, str]:
try:
path.mkdir(parents=True, exist_ok=True)
except OSError as e:
return False, f"cannot create dir {path}: {e}"
try:
test = path / ".pobsync_write_test"
test.write_text("test", encoding="utf-8")
test.unlink(missing_ok=True)
except OSError as e:
return False, f"not writable: {path}: {e}"
return True, f"ok: writable {path}"
def run_doctor(prefix: Path, host: str | None, connect: bool, rsync_dry_run: bool) -> dict[str, Any]:
# Phase 1 doctor does not perform network checks yet (connect/rsync_dry_run acknowledged).
paths = PobsyncPaths(home=prefix)
results: list[dict[str, Any]] = []
ok = True
# Check required layout
for d in (paths.config_dir, paths.hosts_dir, paths.state_dir, paths.locks_dir, paths.logs_dir):
exists = d.exists()
results.append({"check": "path_exists", "path": str(d), "ok": exists})
if not exists:
ok = False
# Load and validate global config
global_cfg: dict[str, Any] | None = None
if paths.global_config_path.exists():
try:
global_cfg = load_global_config(paths.global_config_path)
results.append({"check": "global_config", "path": str(paths.global_config_path), "ok": True})
except Exception as e:
ok = False
results.append({"check": "global_config", "path": str(paths.global_config_path), "ok": False, "error": str(e)})
else:
ok = False
results.append({"check": "global_config", "path": str(paths.global_config_path), "ok": False, "error": "missing"})
# Basic binaries
b1, m1 = _check_binary("rsync")
results.append({"check": "binary", "name": "rsync", "ok": b1, "message": m1})
ok = ok and b1
b2, m2 = _check_binary("ssh")
results.append({"check": "binary", "name": "ssh", "ok": b2, "message": m2})
ok = ok and b2
# backup_root checks
if global_cfg is not None:
backup_root = global_cfg.get("backup_root")
if isinstance(backup_root, str) and is_absolute_non_root(backup_root):
br = Path(backup_root)
w_ok, w_msg = _check_writable_dir(br)
results.append({"check": "backup_root", "path": str(br), "ok": w_ok, "message": w_msg})
ok = ok and w_ok
else:
ok = False
results.append({"check": "backup_root", "ok": False, "error": "invalid backup_root"})
else:
results.append({"check": "backup_root", "ok": False, "error": "global config not loaded"})
# host checks
if host is not None:
host_path = paths.hosts_dir / f"{host}.yaml"
if not host_path.exists():
ok = False
results.append({"check": "host_config", "host": host, "ok": False, "error": f"missing {host_path}"})
else:
try:
_ = load_host_config(host_path)
results.append({"check": "host_config", "host": host, "ok": True, "path": str(host_path)})
except Exception as e:
ok = False
results.append({"check": "host_config", "host": host, "ok": False, "path": str(host_path), "error": str(e)})
# Phase 1: report that connect/rsync_dry_run are not implemented yet
if connect:
results.append({"check": "connect", "ok": False, "error": "not implemented in phase 1"})
ok = False
if rsync_dry_run:
results.append({"check": "rsync_dry_run", "ok": False, "error": "not implemented in phase 1"})
ok = False
if not ok:
# Do not raise; return structured report. CLI will map to exit code 1.
return {"ok": False, "results": results}
return {"ok": True, "results": results}

View File

@@ -1,82 +0,0 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
from ..errors import ConfigError
from ..paths import PobsyncPaths
from ..util import sanitize_host
def build_host_config(
host: str,
address: str,
retention: dict[str, int],
ssh_user: str | None = None,
ssh_port: int | None = None,
excludes_add: list[str] | None = None,
excludes_replace: list[str] | None = None,
includes: list[str] | None = None,
) -> dict[str, Any]:
cfg: dict[str, Any] = {
"host": host,
"address": address,
"retention": retention,
"includes": includes or [],
}
if ssh_user is not None or ssh_port is not None:
cfg["ssh"] = {}
if ssh_user is not None:
cfg["ssh"]["user"] = ssh_user
if ssh_port is not None:
cfg["ssh"]["port"] = ssh_port
if excludes_replace is not None:
cfg["excludes_replace"] = excludes_replace
else:
cfg["excludes_add"] = excludes_add or []
return cfg
def run_init_host(
prefix: Path,
host: str,
address: str,
retention: dict[str, int],
ssh_user: str | None,
ssh_port: int | None,
excludes_add: list[str],
excludes_replace: list[str] | None,
includes: list[str],
dry_run: bool,
force: bool,
) -> dict[str, Any]:
host = sanitize_host(host)
paths = PobsyncPaths(home=prefix)
target = paths.hosts_dir / f"{host}.yaml"
if target.exists() and not force:
raise ConfigError(f"Host config already exists: {target} (use --force to overwrite)")
cfg = build_host_config(
host=host,
address=address,
retention=retention,
ssh_user=ssh_user,
ssh_port=ssh_port,
excludes_add=excludes_add,
excludes_replace=excludes_replace,
includes=includes,
)
action: str
if dry_run:
action = f"would write {target}"
else:
target.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8")
action = f"wrote {target}"
return {"ok": True, "action": action, "host_config": str(target)}

View File

@@ -1,129 +0,0 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
from ..errors import InstallError
from ..paths import PobsyncPaths
from ..util import ensure_dir, is_absolute_non_root
DEFAULT_EXCLUDES = [
"/proc/***",
"/sys/***",
"/dev/***",
"/run/***",
"/tmp/***",
"/mnt/***",
"/media/***",
"/lost+found/***",
"/var/cache/***",
"/var/tmp/***",
"/var/run/***",
"/var/lock/***",
"/swapfile",
"/.snapshots/***",
]
DEFAULT_RSYNC_ARGS = [
"--archive",
"--numeric-ids",
"--delete",
"--delete-excluded",
"--partial",
"--partial-dir=.rsync-partial",
"--one-file-system",
"--relative",
"--human-readable",
"--stats",
]
def build_default_global_config(pobsync_home: Path, backup_root: str, retention: dict[str, int]) -> dict[str, Any]:
return {
"backup_root": backup_root,
"pobsync_home": str(pobsync_home),
"ssh": {
"user": "root",
"port": 22,
"options": [
"-oBatchMode=yes",
"-oStrictHostKeyChecking=accept-new",
],
},
"rsync": {
"binary": "rsync",
"args": DEFAULT_RSYNC_ARGS,
"timeout_seconds": 0,
"bwlimit_kbps": 0,
"extra_args": [],
},
"defaults": {
"source_root": "/",
"destination_subdir": "",
},
"excludes_default": DEFAULT_EXCLUDES,
"logging": {
"file": str(pobsync_home / "logs" / "pobsync.log"),
"level": "INFO",
},
"output": {
"default_format": "human",
},
# We store default retention here for init-host convenience; host config still requires retention.
"retention_defaults": retention,
}
def install_layout(paths: PobsyncPaths, dry_run: bool) -> list[str]:
actions: list[str] = []
for d in (paths.home, paths.config_dir, paths.hosts_dir, paths.state_dir, paths.locks_dir, paths.logs_dir):
actions.append(f"mkdir -p {d}")
if not dry_run:
ensure_dir(d)
return actions
def write_yaml(path: Path, data: dict[str, Any], dry_run: bool, force: bool) -> str:
if path.exists() and not force:
return f"skip existing {path}"
if path.exists() and force:
bak = path.with_suffix(path.suffix + ".bak")
if not dry_run:
bak.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
return f"overwrite {path} (backup {bak})"
if not dry_run:
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
return f"write {path}"
def run_install(
prefix: Path,
backup_root: str | None,
retention: dict[str, int],
dry_run: bool,
force: bool,
) -> dict[str, Any]:
if backup_root is None:
raise InstallError("backup_root is required (use --backup-root or interactive mode)")
if not is_absolute_non_root(backup_root):
raise InstallError("backup_root must be an absolute path and must not be '/'")
paths = PobsyncPaths(home=prefix)
actions = install_layout(paths, dry_run=dry_run)
global_cfg = build_default_global_config(paths.home, backup_root=backup_root, retention=retention)
actions.append(write_yaml(paths.global_config_path, global_cfg, dry_run=dry_run, force=force))
return {
"ok": True,
"actions": actions,
"paths": {
"home": str(paths.home),
"global_config": str(paths.global_config_path),
},
}

View File

@@ -1,24 +0,0 @@
from __future__ import annotations
from pathlib import Path
from ..paths import PobsyncPaths
from ..util import sanitize_host
def run_list_remotes(prefix: Path) -> dict:
paths = PobsyncPaths(home=prefix)
hosts: list[str] = []
if paths.hosts_dir.exists():
for p in sorted(paths.hosts_dir.glob("*.yaml")):
host = p.stem
try:
sanitize_host(host)
except Exception:
# Ignore invalid filenames; doctor will catch config issues.
continue
hosts.append(host)
return {"ok": True, "hosts": hosts}

View File

@@ -0,0 +1,110 @@
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any, Dict, List
from ..config.source import ConfigSource
from ..errors import ConfigError
from ..lock import acquire_host_lock
from ..paths import PobsyncPaths
from ..util import sanitize_host
from .retention_plan import run_retention_plan
def run_retention_apply(
prefix: Path,
host: str,
kind: str,
protect_bases: bool,
yes: bool,
max_delete: int,
acquire_lock: bool = True,
config_source: ConfigSource | None = None,
) -> dict[str, Any]:
host = sanitize_host(host)
if kind not in {"scheduled", "manual", "all"}:
raise ConfigError("kind must be scheduled, manual, or all")
if not yes:
raise ConfigError("Refusing to delete snapshots without --yes")
if max_delete < 0:
raise ConfigError("--max-delete must be >= 0")
paths = PobsyncPaths(home=prefix)
def _do_apply() -> dict[str, Any]:
plan = run_retention_plan(
prefix=prefix,
host=host,
kind=kind,
protect_bases=bool(protect_bases),
config_source=config_source,
)
delete_list = plan.get("delete") or []
if not isinstance(delete_list, list):
raise ConfigError("Invalid retention plan output: delete is not a list")
if max_delete == 0 and len(delete_list) > 0:
raise ConfigError("Deletion blocked by --max-delete=0")
if len(delete_list) > max_delete:
raise ConfigError(f"Refusing to delete {len(delete_list)} snapshots (exceeds --max-delete={max_delete})")
actions: List[str] = []
deleted: List[Dict[str, Any]] = []
for item in delete_list:
if not isinstance(item, dict):
continue
dirname = item.get("dirname")
snap_kind = item.get("kind")
snap_path = item.get("path")
if not isinstance(dirname, str) or not isinstance(snap_kind, str) or not isinstance(snap_path, str):
continue
# Hard safety: only allow scheduled/manual deletions from plan
if snap_kind not in {"scheduled", "manual"}:
raise ConfigError(f"Refusing to delete unsupported snapshot kind: {snap_kind!r}")
p = Path(snap_path)
if not p.exists():
actions.append(f"skip missing {snap_kind}/{dirname}")
continue
if not p.is_dir():
raise ConfigError(f"Refusing to delete non-directory path: {snap_path}")
shutil.rmtree(p)
actions.append(f"deleted {snap_kind} {dirname}")
deleted.append(
{
"dirname": dirname,
"kind": snap_kind,
"path": snap_path,
}
)
return {
"ok": True,
"host": host,
"kind": kind,
"protect_bases": bool(protect_bases),
"max_delete": max_delete,
"deleted": deleted,
"actions": actions,
}
if acquire_lock:
with acquire_host_lock(paths.locks_dir, host, command="retention-apply"):
return _do_apply()
# Caller guarantees locking (used by run-scheduled)
return _do_apply()

View File

@@ -0,0 +1,112 @@
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, List
from ..config.source import ConfigSource
from ..errors import ConfigError
from ..retention import Snapshot, apply_base_protection, build_retention_plan
from ..snapshot_meta import iter_snapshot_dirs, read_snapshot_meta, resolve_host_root
from ..util import sanitize_host
def _parse_snapshot_dt(dirname: str, meta: dict) -> datetime:
ts = meta.get("started_at")
if isinstance(ts, str) and ts.endswith("Z"):
try:
return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
except ValueError:
pass
# fallback: dirname YYYYMMDD-HHMMSSZ__ID
try:
prefix = dirname.split("__", 1)[0]
return datetime.strptime(prefix, "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
except Exception:
return datetime.fromtimestamp(0, tz=timezone.utc)
def run_retention_plan(
prefix: Path,
host: str,
kind: str,
protect_bases: bool,
config_source: ConfigSource | None = None,
) -> dict[str, Any]:
host = sanitize_host(host)
if kind not in {"scheduled", "manual", "all"}:
raise ConfigError("kind must be scheduled, manual, or all")
if config_source is None:
raise ConfigError("A Django config source is required.")
cfg = config_source.effective_config_for_host(host)
retention = cfg.get("retention")
if not isinstance(retention, dict):
raise ConfigError("No retention config found")
backup_root = cfg.get("backup_root")
if not isinstance(backup_root, str) or not backup_root.startswith("/"):
raise ConfigError("Invalid backup_root in config")
host_root = resolve_host_root(backup_root, host)
kinds: List[str]
if kind == "all":
kinds = ["scheduled", "manual"]
else:
kinds = [kind]
snapshots: List[Snapshot] = []
for kk in kinds:
for d in iter_snapshot_dirs(host_root, kk):
meta = read_snapshot_meta(d)
dt = _parse_snapshot_dt(d.name, meta)
snapshots.append(
Snapshot(
kind=kk,
dirname=d.name,
path=str(d),
dt=dt,
status=meta.get("status"),
base=meta.get("base"),
)
)
plan = build_retention_plan(
snapshots=snapshots,
retention=retention,
now=datetime.now(timezone.utc),
)
keep = set(plan.keep)
reasons = dict(plan.reasons)
if protect_bases:
keep, reasons = apply_base_protection(snapshots=snapshots, keep=keep, reasons=reasons)
delete = [s for s in snapshots if s.dirname not in keep]
return {
"ok": True,
"host": host,
"kind": kind,
"protect_bases": bool(protect_bases),
"retention": retention,
"keep": sorted(keep),
"delete": [
{
"dirname": s.dirname,
"kind": s.kind,
"path": s.path,
"dt": s.dt.isoformat(),
"status": s.status,
}
for s in delete
],
"reasons": reasons,
}

View File

@@ -1,16 +1,14 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from typing import Any, Callable
import yaml
from ..config.load import load_global_config, load_host_config
from ..config.merge import build_effective_config
from ..config.source import ConfigSource
from ..errors import ConfigError
from ..lock import acquire_host_lock
from ..paths import PobsyncPaths
from ..rsync import build_rsync_command, build_ssh_command, run_rsync
from ..run_stats import collect_storage_stats, read_rsync_stats
from ..snapshot import (
HostBackupDirs,
extract_ts_and_id_from_dirname,
@@ -20,7 +18,93 @@ from ..snapshot import (
snapshot_dir_name,
utc_now,
)
from ..util import ensure_dir, realpath_startswith, sanitize_host
from ..snapshot_meta import read_snapshot_meta
from ..util import ensure_dir, realpath_startswith, sanitize_host, write_yaml_atomic
DEFAULT_DRY_RUN_TIMEOUT_SECONDS = 900
RSYNC_PARTIAL_VANISHED_EXIT_CODE = 24
def dry_run_log_path(host: str, run_id: int | None = None) -> Path:
host = sanitize_host(host)
run_dir = f"run-{run_id}" if run_id is not None else "adhoc"
return Path(f"/tmp/pobsync-dryrun/{host}/{run_dir}/rsync.log")
def _read_log_tail(log_path: Path, *, max_lines: int = 40) -> list[str]:
try:
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return []
return lines[-max_lines:]
def classify_rsync_failure(exit_code: int | None, log_tail: list[str]) -> dict[str, str]:
joined_tail = "\n".join(log_tail).lower()
if exit_code == 255 and "broken pipe" in joined_tail:
return {
"category": "transport",
"message": "Rsync transport closed unexpectedly.",
"hint": "The SSH/rsync stream ended with a broken pipe. Check remote rsync availability, remote shell output, excludes, and connection stability.",
}
if exit_code == 255:
return {
"category": "transport",
"message": "Rsync transport failed.",
"hint": "Exit 255 usually comes from SSH or remote rsync startup. Check SSH access, known_hosts, remote rsync, and remote shell output.",
}
if exit_code == 124:
return {
"category": "timeout",
"message": "Rsync timed out.",
"hint": "Increase the rsync timeout or narrow the backup scope with source root, includes, or excludes.",
}
if "permission denied" in joined_tail:
return {
"category": "permissions",
"message": "Rsync hit a permission error.",
"hint": "Check the SSH user, key, and permissions on the remote source.",
}
return {
"category": "rsync",
"message": "Rsync failed.",
"hint": "Check the rsync log tail for the underlying error.",
}
def classify_rsync_warning(exit_code: int | None, log_tail: list[str]) -> dict[str, str] | None:
joined_tail = "\n".join(log_tail).lower()
if exit_code == RSYNC_PARTIAL_VANISHED_EXIT_CODE:
return {
"category": "vanished",
"message": "Some source files vanished during rsync.",
"hint": "This is common on live systems. The snapshot was kept, but review the rsync log if this happens often.",
}
if exit_code in (None, RSYNC_PARTIAL_VANISHED_EXIT_CODE) and (
"file has vanished" in joined_tail or "vanished before it could be transferred" in joined_tail
):
return {
"category": "vanished",
"message": "Some source files vanished during rsync.",
"hint": "This is common on live systems. The snapshot was kept, but review the rsync log if this happens often.",
}
return None
def _collect_run_stats(
*,
log_path: Path,
backup_root: Path,
duration_seconds: int | None = None,
snapshot_dir: Path | None = None,
) -> dict[str, Any]:
stats: dict[str, Any] = {
"rsync": read_rsync_stats(log_path),
"storage": collect_storage_stats(backup_root=backup_root, snapshot_dir=snapshot_dir),
}
if duration_seconds is not None:
stats["duration_seconds"] = int(duration_seconds)
return stats
def _host_backup_dirs(backup_root: str, host: str) -> HostBackupDirs:
@@ -48,7 +132,7 @@ def _find_latest_snapshot(parent: Path) -> Path | None:
def select_scheduled_base(dirs: HostBackupDirs) -> Path | None:
"""
Base selection rule:
scheduled -> manual -> none
scheduled -> manual -> none
"""
base = _find_latest_snapshot(dirs.scheduled)
if base is not None:
@@ -56,18 +140,52 @@ def select_scheduled_base(dirs: HostBackupDirs) -> Path | None:
return _find_latest_snapshot(dirs.manual)
def write_meta(path: Path, data: dict[str, Any]) -> None:
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
def _base_meta_from_path(base_dir: Path | None, link_dest: str | None) -> dict[str, Any] | None:
"""
Build base metadata for meta.yaml.
Important: link_dest is the actual rsync --link-dest directory.
For our snapshot layout, that must be "<snapshot_dir>/data".
"""
if base_dir is None:
return None
kind = base_dir.parent.name
if kind not in ("scheduled", "manual"):
# Should not happen with current selection logic, but keep meta robust.
kind = "unknown"
base_meta = read_snapshot_meta(base_dir)
base_id = base_meta.get("id") if isinstance(base_meta.get("id"), str) else None
return {
"kind": kind,
"dirname": base_dir.name,
"id": base_id,
"path": link_dest,
}
def run_scheduled(prefix: Path, host: str, dry_run: bool) -> dict[str, Any]:
def run_scheduled(
prefix: Path,
host: str,
dry_run: bool,
prune: bool = False,
prune_max_delete: int | None = None,
prune_protect_bases: bool = False,
config_source: ConfigSource | None = None,
run_id: int | None = None,
cancel_check: Callable[[], bool] | None = None,
verbose_output: bool = False,
state_callback: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
host = sanitize_host(host)
paths = PobsyncPaths(home=prefix)
# Load and merge config
global_cfg = load_global_config(paths.global_config_path)
host_cfg = load_host_config(paths.hosts_dir / f"{host}.yaml")
cfg = build_effective_config(global_cfg, host_cfg)
if config_source is None:
raise ConfigError("A Django config source is required.")
cfg = config_source.effective_config_for_host(host)
backup_root = cfg.get("backup_root")
if not isinstance(backup_root, str) or not backup_root.startswith("/"):
@@ -81,7 +199,10 @@ def run_scheduled(prefix: Path, host: str, dry_run: bool) -> dict[str, Any]:
# Base snapshot (absolute path)
base_dir = select_scheduled_base(dirs)
link_dest = str(base_dir) if base_dir else None
# BUGFIX: rsync --link-dest must point at the snapshot "data" root, not the snapshot dir itself.
# Our destination root is "<incomplete>/data/", so the base root must be "<base>/data/".
link_dest = str(base_dir / "data") if base_dir else None
ssh_cfg = cfg.get("ssh", {}) or {}
rsync_cfg = cfg.get("rsync", {}) or {}
@@ -111,8 +232,10 @@ def run_scheduled(prefix: Path, host: str, dry_run: bool) -> dict[str, Any]:
# DRY RUN
# ------------------------------------------------------------
if dry_run:
dest = f"/tmp/pobsync-dryrun/{host}/"
dryrun_log = Path(f"/tmp/pobsync-dryrun/{host}/rsync.log")
dryrun_log = dry_run_log_path(host, run_id=run_id)
dest = str(dryrun_log.parent) + "/"
dryrun_log.unlink(missing_ok=True)
effective_timeout_seconds = timeout_seconds or DEFAULT_DRY_RUN_TIMEOUT_SECONDS
cmd = build_rsync_command(
rsync_binary=str(rsync_binary),
@@ -122,25 +245,45 @@ def run_scheduled(prefix: Path, host: str, dry_run: bool) -> dict[str, Any]:
dest=dest,
link_dest=link_dest,
dry_run=True,
timeout_seconds=timeout_seconds,
timeout_seconds=effective_timeout_seconds,
bwlimit_kbps=bwlimit_kbps,
extra_excludes=list(excludes),
extra_includes=list(includes),
)
result = run_rsync(cmd, log_path=dryrun_log, timeout_seconds=timeout_seconds)
result = run_rsync(
cmd,
log_path=dryrun_log,
timeout_seconds=effective_timeout_seconds,
cancel_check=cancel_check,
)
log_tail = _read_log_tail(dryrun_log)
stats = _collect_run_stats(
log_path=dryrun_log,
backup_root=Path(backup_root),
)
return {
response = {
"ok": result.exit_code == 0,
"dry_run": True,
"host": host,
"base": str(base_dir) if base_dir else None,
"log": str(dryrun_log),
"cancelled": result.cancelled,
"timeout_seconds": effective_timeout_seconds,
"verbose_output": True,
"ssh_credential": cfg.get("ssh_credential"),
"stats": stats,
"rsync": {
"exit_code": result.exit_code,
"command": result.command,
"log_tail": log_tail,
"bwlimit_kbps": bwlimit_kbps,
},
}
if result.exit_code != 0:
response["failure"] = classify_rsync_failure(result.exit_code, log_tail)
return response
# ------------------------------------------------------------
# REAL RUN
@@ -158,31 +301,15 @@ def run_scheduled(prefix: Path, host: str, dry_run: bool) -> dict[str, Any]:
incomplete_dir = dirs.incomplete / snap_name
data_dir = incomplete_dir / "data"
meta_dir = incomplete_dir / "meta"
ensure_dir(data_dir)
ensure_dir(meta_dir)
meta_path = meta_dir / "meta.yaml"
log_path = meta_dir / "rsync.log"
meta: dict[str, Any] = {
"id": snap_id,
"host": host,
"type": "scheduled",
"label": None,
"status": "running",
"started_at": format_iso_z(ts),
"ended_at": None,
"base_snapshot": None,
"rsync": {"exit_code": None, "stats": {}},
"overrides": {"includes": [], "excludes": [], "base": None},
}
log_path.touch(exist_ok=True)
write_meta(meta_path, meta)
# Pre-build command so we can record it in metadata.
dest = str(data_dir) + "/"
cmd = build_rsync_command(
rsync_binary=str(rsync_binary),
rsync_args=list(rsync_args),
@@ -195,20 +322,91 @@ def run_scheduled(prefix: Path, host: str, dry_run: bool) -> dict[str, Any]:
bwlimit_kbps=bwlimit_kbps,
extra_excludes=list(excludes),
extra_includes=list(includes),
verbose_output=bool(verbose_output),
)
result = run_rsync(cmd, log_path=log_path, timeout_seconds=timeout_seconds)
meta: dict[str, Any] = {
"schema_version": 1,
"id": snap_id,
"host": host,
"type": "scheduled",
"label": None,
"verbose_output": bool(verbose_output),
"status": "running",
"started_at": format_iso_z(ts),
"ended_at": None,
"duration_seconds": None,
"base": _base_meta_from_path(base_dir, link_dest),
"rsync": {"exit_code": None, "command": cmd, "stats": {}, "bwlimit_kbps": bwlimit_kbps},
"overrides": {"includes": [], "excludes": [], "base": None},
}
log_path.touch(exist_ok=True)
write_yaml_atomic(meta_path, meta)
if state_callback is not None:
state_callback(
{
"status": "running",
"phase": "preparing",
"snapshot": str(incomplete_dir),
"log": str(log_path),
"rsync": {"command": cmd, "exit_code": None, "bwlimit_kbps": bwlimit_kbps},
}
)
def process_started(pid: int, pgid: int) -> None:
if state_callback is None:
return
state_callback(
{
"status": "running",
"phase": "rsync",
"snapshot": str(incomplete_dir),
"log": str(log_path),
"rsync": {"command": cmd, "exit_code": None, "pid": pid, "pgid": pgid, "bwlimit_kbps": bwlimit_kbps},
}
)
run_rsync_kwargs: dict[str, Any] = {
"log_path": log_path,
"timeout_seconds": timeout_seconds,
"cancel_check": cancel_check,
}
if state_callback is not None:
run_rsync_kwargs["process_started"] = process_started
result = run_rsync(cmd, **run_rsync_kwargs)
log_tail = _read_log_tail(log_path)
warning = classify_rsync_warning(result.exit_code, log_tail)
successful_or_warning = result.exit_code == 0 or warning is not None
if state_callback is not None:
state_callback(
{
"status": "running",
"phase": "finalizing",
"snapshot": str(incomplete_dir),
"log": str(log_path),
"rsync": {"command": cmd, "exit_code": result.exit_code, "log_tail": log_tail, "bwlimit_kbps": bwlimit_kbps},
}
)
end_ts = utc_now()
meta["ended_at"] = format_iso_z(end_ts)
meta["duration_seconds"] = int((end_ts - ts).total_seconds())
meta["rsync"]["exit_code"] = result.exit_code
meta["status"] = "success" if result.exit_code == 0 else "failed"
write_meta(meta_path, meta)
meta["status"] = "cancelled" if result.cancelled else ("warning" if warning else ("success" if result.exit_code == 0 else "failed"))
if warning is not None:
meta["warning"] = warning
meta["stats"] = _collect_run_stats(
log_path=log_path,
backup_root=Path(backup_root),
duration_seconds=meta["duration_seconds"],
)
write_yaml_atomic(meta_path, meta)
if not log_path.exists():
meta["status"] = "failed"
meta["rsync"]["exit_code"] = 99
write_meta(meta_path, meta)
write_yaml_atomic(meta_path, meta)
return {
"ok": False,
"dry_run": False,
@@ -217,18 +415,53 @@ def run_scheduled(prefix: Path, host: str, dry_run: bool) -> dict[str, Any]:
"error": "rsync.log missing after execution",
}
if result.exit_code != 0:
if not successful_or_warning:
return {
"ok": False,
"dry_run": False,
"host": host,
"snapshot": str(incomplete_dir),
"status": meta["status"],
"rsync": {"exit_code": result.exit_code},
"cancelled": result.cancelled,
"log": str(log_path),
"verbose_output": bool(verbose_output),
"ssh_credential": cfg.get("ssh_credential"),
"stats": meta["stats"],
"rsync": {
"exit_code": result.exit_code,
"command": result.command,
"log_tail": log_tail,
"bwlimit_kbps": bwlimit_kbps,
},
"failure": classify_rsync_failure(result.exit_code, log_tail),
}
final_dir = dirs.scheduled / snap_name
incomplete_dir.rename(final_dir)
final_log_path = final_dir / "meta" / "rsync.log"
final_meta_path = final_dir / "meta" / "meta.yaml"
meta["stats"] = _collect_run_stats(
log_path=final_log_path,
backup_root=Path(backup_root),
duration_seconds=meta["duration_seconds"],
snapshot_dir=final_dir / "data",
)
write_yaml_atomic(final_meta_path, meta)
prune_result = None
if prune:
from .retention_apply import run_retention_apply
prune_result = run_retention_apply(
prefix=paths.home,
host=host,
kind="scheduled",
protect_bases=bool(prune_protect_bases),
yes=True,
max_delete=10 if prune_max_delete is None else int(prune_max_delete),
acquire_lock=False,
config_source=source,
)
return {
"ok": True,
@@ -236,6 +469,12 @@ def run_scheduled(prefix: Path, host: str, dry_run: bool) -> dict[str, Any]:
"host": host,
"snapshot": str(final_dir),
"base": str(base_dir) if base_dir else None,
"rsync": {"exit_code": result.exit_code},
"log": str(final_log_path),
"status": meta["status"],
"warning": warning,
"rsync": {"exit_code": result.exit_code, "command": result.command, "log_tail": log_tail, "bwlimit_kbps": bwlimit_kbps},
"verbose_output": bool(verbose_output),
"duration_seconds": meta["duration_seconds"],
"stats": meta["stats"],
"prune": prune_result,
}

View File

@@ -1,33 +0,0 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
from ..config.load import load_global_config, load_host_config
from ..config.merge import build_effective_config
from ..paths import PobsyncPaths
from ..util import sanitize_host
def run_show_config(prefix: Path, host: str, effective: bool) -> dict[str, Any]:
host = sanitize_host(host)
paths = PobsyncPaths(home=prefix)
global_cfg = load_global_config(paths.global_config_path)
host_cfg = load_host_config(paths.hosts_dir / f"{host}.yaml")
cfg = build_effective_config(global_cfg, host_cfg) if effective else host_cfg
return {
"ok": True,
"host": host,
"effective": effective,
"config": cfg,
}
def dump_yaml(data: Any) -> str:
return yaml.safe_dump(data, sort_keys=False)

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
DEFAULT_EXCLUDES = [
"/proc/***",
"/sys/***",
"/dev/***",
"/run/***",
"/tmp/***",
"/mnt/***",
"/media/***",
"/lost+found/***",
"/var/cache/***",
"/var/tmp/***",
"/var/run/***",
"/var/lock/***",
"/swapfile",
"/.snapshots/***",
]
DEFAULT_RSYNC_ARGS = [
"--archive",
"--numeric-ids",
"--delete",
"--delete-excluded",
"--partial",
"--partial-dir=.rsync-partial",
"--one-file-system",
"--relative",
"--human-readable",
"--stats",
]

View File

@@ -1,53 +0,0 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
from ..errors import ConfigError, ValidationError
from ..validate import validate_dict
from .schemas import GLOBAL_SCHEMA, HOST_SCHEMA
def load_yaml_file(path: Path) -> dict[str, Any]:
if not path.exists():
raise ConfigError(f"Missing config file: {path}")
try:
raw = path.read_text(encoding="utf-8")
except OSError as e:
raise ConfigError(f"Cannot read config file: {path}: {e}") from e
try:
data = yaml.safe_load(raw)
except yaml.YAMLError as e:
raise ConfigError(f"Invalid YAML in {path}: {e}") from e
if data is None:
data = {}
if not isinstance(data, dict):
raise ConfigError(f"Config root must be a mapping in {path}")
return data
def load_global_config(path: Path) -> dict[str, Any]:
data = load_yaml_file(path)
try:
return validate_dict(data, GLOBAL_SCHEMA, path="global")
except ValidationError as e:
raise ConfigError(f"Invalid global config at {path}: {format_validation_error(e)}") from e
def load_host_config(path: Path) -> dict[str, Any]:
data = load_yaml_file(path)
try:
return validate_dict(data, HOST_SCHEMA, path="host")
except ValidationError as e:
raise ConfigError(f"Invalid host config at {path}: {format_validation_error(e)}") from e
def format_validation_error(err: ValidationError) -> str:
if err.path:
return f"{err.path}: {err}"
return str(err)

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
def parse_retention(s: str) -> dict[str, int]:
out: dict[str, int] = {}
parts = [p.strip() for p in s.split(",") if p.strip()]
for part in parts:
if "=" not in part:
raise ValueError(f"Invalid retention component: {part!r}")
k, v = part.split("=", 1)
k = k.strip()
v = v.strip()
if k not in {"daily", "weekly", "monthly", "yearly"}:
raise ValueError(f"Invalid retention key: {k!r}")
n = int(v)
if n < 0:
raise ValueError(f"Retention must be >= 0 for {k}")
out[k] = n
for k in ("daily", "weekly", "monthly", "yearly"):
out.setdefault(k, 0)
return out

View File

@@ -83,7 +83,6 @@ OUTPUT_SCHEMA = Schema(
GLOBAL_SCHEMA = Schema(
fields={
"backup_root": FieldSpec(str, required=True),
"pobsync_home": FieldSpec(str, required=False, default="/opt/pobsync"),
"ssh": FieldSpec(dict, required=False, schema=SSH_SCHEMA),
"rsync": FieldSpec(dict, required=False, schema=RSYNC_SCHEMA),
"defaults": FieldSpec(dict, required=False, schema=DEFAULTS_SCHEMA),
@@ -95,7 +94,6 @@ GLOBAL_SCHEMA = Schema(
),
"logging": FieldSpec(dict, required=False, schema=LOGGING_SCHEMA),
"output": FieldSpec(dict, required=False, schema=OUTPUT_SCHEMA),
# Used by `init-host` as a convenience default
"retention_defaults": FieldSpec(
dict,
required=False,
@@ -112,6 +110,7 @@ GLOBAL_SCHEMA = Schema(
HOST_RSYNC_SCHEMA = Schema(
fields={
"bwlimit_kbps": FieldSpec(int, required=False, min_value=0),
"extra_args": FieldSpec(list, required=False, default=[], item=FieldSpec(str)),
},
allow_unknown=False,
@@ -131,4 +130,3 @@ HOST_SCHEMA = Schema(
},
allow_unknown=False,
)

View File

@@ -0,0 +1,8 @@
from __future__ import annotations
from typing import Any, Protocol
class ConfigSource(Protocol):
def effective_config_for_host(self, host: str) -> dict[str, Any]:
"""Return the fully merged effective config for a host."""

View File

@@ -8,14 +8,6 @@ from pathlib import Path
class PobsyncPaths:
home: Path # usually /opt/pobsync
@property
def config_dir(self) -> Path:
return self.home / "config"
@property
def hosts_dir(self) -> Path:
return self.config_dir / "hosts"
@property
def state_dir(self) -> Path:
return self.home / "state"
@@ -28,11 +20,6 @@ class PobsyncPaths:
def logs_dir(self) -> Path:
return self.home / "logs"
@property
def global_config_path(self) -> Path:
return self.config_dir / "global.yaml"
@property
def central_log_path(self) -> Path:
return self.logs_dir / "pobsync.log"

172
src/pobsync/retention.py Normal file
View File

@@ -0,0 +1,172 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
@dataclass(frozen=True)
class Snapshot:
kind: str # scheduled | manual
dirname: str
path: str
dt: datetime # UTC
status: Optional[str]
base: Optional[dict]
@dataclass
class RetentionResult:
keep: Set[str] # dirnames
reasons: Dict[str, List[str]]
def _bucket_day(dt: datetime) -> str:
return dt.strftime("%Y-%m-%d")
def _bucket_week(dt: datetime) -> str:
iso = dt.isocalendar()
return f"{iso.year}-W{iso.week:02d}"
def _bucket_month(dt: datetime) -> str:
return dt.strftime("%Y-%m")
def _bucket_year(dt: datetime) -> str:
return dt.strftime("%Y")
def _window_start(now: datetime, unit: str, count: int) -> datetime:
if count <= 0:
return now + timedelta(days=1)
if unit == "daily":
return (now - timedelta(days=count - 1)).replace(hour=0, minute=0, second=0, microsecond=0)
if unit == "weekly":
return now - timedelta(weeks=count - 1)
if unit == "monthly":
return now.replace(day=1) - timedelta(days=32 * (count - 1))
if unit == "yearly":
return now.replace(month=1, day=1) - timedelta(days=366 * (count - 1))
raise ValueError(unit)
def build_retention_plan(
snapshots: Iterable[Snapshot],
retention: Dict[str, int],
now: Optional[datetime] = None,
) -> RetentionResult:
"""
Build a dry-run retention plan.
Returns:
- keep: set of snapshot dirnames to keep
- reasons: mapping dirname -> list of reasons why it is kept
"""
if now is None:
now = datetime.now(timezone.utc)
snaps = sorted(snapshots, key=lambda s: s.dt, reverse=True)
keep: Set[str] = set()
reasons: Dict[str, List[str]] = {}
def mark(dirname: str, reason: str) -> None:
keep.add(dirname)
reasons.setdefault(dirname, []).append(reason)
# Always keep newest snapshot overall (if any)
if snaps:
mark(snaps[0].dirname, "newest")
# Retention buckets
rules = [
("daily", retention.get("daily", 0), _bucket_day),
("weekly", retention.get("weekly", 0), _bucket_week),
("monthly", retention.get("monthly", 0), _bucket_month),
("yearly", retention.get("yearly", 0), _bucket_year),
]
for name, count, bucket_fn in rules:
if count <= 0:
continue
window_start = _window_start(now, name, count)
seen: Set[str] = set()
for s in snaps:
if s.dt < window_start:
break
bucket = bucket_fn(s.dt)
if bucket in seen:
continue
# Prefer successful snapshots, but allow fallback
if s.status not in (None, "success"):
continue
seen.add(bucket)
mark(s.dirname, f"{name}:{bucket}")
# Fallback: if a bucket had no success, allow newest non-success
for s in snaps:
if s.dt < window_start:
break
bucket = bucket_fn(s.dt)
if bucket in seen:
continue
seen.add(bucket)
mark(s.dirname, f"{name}:{bucket}:fallback")
return RetentionResult(keep=keep, reasons=reasons)
def apply_base_protection(
*,
snapshots: Iterable[Snapshot],
keep: Set[str],
reasons: Dict[str, List[str]],
) -> Tuple[Set[str], Dict[str, List[str]]]:
"""
If a kept snapshot has a base (kind+dirname), also keep that base snapshot.
Hardlink snapshots remain readable without this, but keeping bases can make
future base selection and chain inspection easier.
"""
snapshot_list = list(snapshots)
index: Dict[Tuple[str, str], Snapshot] = {(snapshot.kind, snapshot.dirname): snapshot for snapshot in snapshot_list}
changed = True
while changed:
changed = False
for child_dirname in list(keep):
child = _find_snapshot_by_dirname(snapshot_list, child_dirname)
if child is None or not isinstance(child.base, dict):
continue
base_kind = child.base.get("kind")
base_dirname = child.base.get("dirname")
if not isinstance(base_kind, str) or not isinstance(base_dirname, str):
continue
base_snapshot = index.get((base_kind, base_dirname))
if base_snapshot is None or base_dirname in keep:
continue
keep.add(base_dirname)
reasons.setdefault(base_dirname, []).append(f"base-of:{child_dirname}")
changed = True
return keep, reasons
def _find_snapshot_by_dirname(snapshots: Iterable[Snapshot], dirname: str) -> Snapshot | None:
for kind in ("scheduled", "manual"):
for snapshot in snapshots:
if snapshot.kind == kind and snapshot.dirname == dirname:
return snapshot
return None

View File

@@ -1,16 +1,23 @@
from __future__ import annotations
import os
import signal
import shlex
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
from typing import Callable, Sequence
DEFAULT_VERBOSE_OUTPUT_ARGS = ["--itemize-changes", "--info=flist2,progress2,stats2"]
@dataclass(frozen=True)
class RsyncResult:
exit_code: int
command: list[str]
cancelled: bool = False
def build_ssh_command(ssh_cfg: dict) -> list[str]:
@@ -36,10 +43,14 @@ def build_rsync_command(
bwlimit_kbps: int,
extra_excludes: Sequence[str],
extra_includes: Sequence[str],
verbose_output: bool = False,
) -> list[str]:
cmd: list[str] = [rsync_binary]
cmd.extend(list(rsync_args))
_append_stats_arg(cmd)
if dry_run or verbose_output:
_append_default_verbose_output_args(cmd)
# includes/excludes: keep it simple for now:
# - if includes are provided, user is responsible for correct rsync include logic.
@@ -66,7 +77,13 @@ def build_rsync_command(
def run_rsync(command: list[str], log_path: Path, timeout_seconds: int) -> RsyncResult:
def run_rsync(
command: list[str],
log_path: Path,
timeout_seconds: int,
cancel_check: Callable[[], bool] | None = None,
process_started: Callable[[int, int], None] | None = None,
) -> RsyncResult:
"""
Run rsync and always write stdout/stderr to log_path.
@@ -77,17 +94,56 @@ def run_rsync(command: list[str], log_path: Path, timeout_seconds: int) -> Rsync
# Ensure the file exists early.
log_path.touch(exist_ok=True)
with log_path.open("ab") as f:
process = subprocess.Popen(command, stdout=f, stderr=subprocess.STDOUT, start_new_session=True)
if process_started is not None:
process_started(process.pid, os.getpgid(process.pid))
started = time.monotonic()
while True:
exit_code = process.poll()
if exit_code is not None:
return RsyncResult(exit_code=exit_code, command=command)
if cancel_check is not None and cancel_check():
_terminate_process_group(process)
f.write(b"\n[pobsync] rsync cancelled\n")
return RsyncResult(exit_code=130, command=command, cancelled=True)
if timeout_seconds > 0 and time.monotonic() - started >= timeout_seconds:
_terminate_process_group(process)
f.write(b"\n[pobsync] rsync timed out\n")
return RsyncResult(exit_code=124, command=command)
time.sleep(1)
def _terminate_process_group(process: subprocess.Popen) -> None:
try:
with log_path.open("ab") as f:
p = subprocess.run(
command,
stdout=f,
stderr=subprocess.STDOUT,
timeout=timeout_seconds if timeout_seconds > 0 else None,
)
return RsyncResult(exit_code=p.returncode, command=command)
except subprocess.TimeoutExpired as e:
# Log timeout info and return a non-zero exit code.
with log_path.open("ab") as f:
f.write(b"\n[pobsync] rsync timed out\n")
return RsyncResult(exit_code=124, command=command)
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=10)
except ProcessLookupError:
return
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait(timeout=10)
def _append_default_verbose_output_args(command: list[str]) -> None:
if not _has_itemize_arg(command):
command.append("--itemize-changes")
if not any(arg.startswith("--info=") for arg in command):
command.append("--info=flist2,progress2,stats2")
def _append_stats_arg(command: list[str]) -> None:
if "--stats" not in command:
command.append("--stats")
def _has_itemize_arg(command: list[str]) -> bool:
for arg in command:
if arg == "--itemize-changes":
return True
if arg.startswith("-") and not arg.startswith("--") and "i" in arg:
return True
return False

238
src/pobsync/run_stats.py Normal file
View File

@@ -0,0 +1,238 @@
from __future__ import annotations
import os
import re
from pathlib import Path
from typing import Any
_COUNT_KEYS = {
"Number of files": "files_total",
"Number of created files": "files_created",
"Number of deleted files": "files_deleted",
"Number of regular files transferred": "files_transferred",
}
_BYTE_KEYS = {
"Total file size": "total_file_size_bytes",
"Total transferred file size": "total_transferred_file_size_bytes",
"Literal data": "literal_data_bytes",
"Matched data": "matched_data_bytes",
"File list size": "file_list_size_bytes",
"Total bytes sent": "bytes_sent",
"Total bytes received": "bytes_received",
}
_SIZE_UNITS = {
"": 1,
"b": 1,
"k": 1000,
"kb": 1000,
"m": 1000**2,
"mb": 1000**2,
"g": 1000**3,
"gb": 1000**3,
"t": 1000**4,
"tb": 1000**4,
"p": 1000**5,
"pb": 1000**5,
}
def parse_rsync_stats(text: str) -> dict[str, Any]:
stats: dict[str, Any] = {}
for raw_line in text.splitlines():
line = raw_line.strip()
if not line:
continue
_parse_colon_stat(line, stats)
_parse_sent_received(line, stats)
_parse_total_size_speedup(line, stats)
_add_derived_stats(stats)
return stats
def read_rsync_stats(log_path: Path) -> dict[str, Any]:
try:
text = log_path.read_text(encoding="utf-8", errors="replace")
except OSError:
return {}
return parse_rsync_stats(text)
def collect_storage_stats(*, backup_root: Path, snapshot_dir: Path | None = None) -> dict[str, Any]:
stats: dict[str, Any] = {
"backup_root": str(backup_root),
}
capacity = filesystem_capacity(backup_root)
if capacity:
stats["capacity"] = capacity
if snapshot_dir is not None:
snapshot_usage = tree_usage(snapshot_dir)
if snapshot_usage:
stats["snapshot"] = {
"path": str(snapshot_dir),
**snapshot_usage,
}
return stats
def filesystem_capacity(path: Path) -> dict[str, Any]:
try:
stat = path.stat()
statvfs = os.statvfs(path)
except OSError:
return {}
total = int(statvfs.f_frsize * statvfs.f_blocks)
available = int(statvfs.f_frsize * statvfs.f_bavail)
free = int(statvfs.f_frsize * statvfs.f_bfree)
used = max(total - free, 0)
return {
"path": str(path),
"total_bytes": total,
"used_bytes": used,
"free_bytes": free,
"available_bytes": available,
"used_percent": round((used / total) * 100, 2) if total else 0.0,
"device": getattr(stat, "st_dev", None),
}
def tree_usage(path: Path) -> dict[str, Any]:
apparent_size = 0
allocated_size = 0
files = 0
directories = 0
hardlinked_files = 0
hardlinked_apparent_size = 0
seen_allocated_inodes: set[tuple[int, int]] = set()
try:
root_stat = path.lstat()
except OSError:
return {}
if path.is_file():
files = 1
apparent_size = root_stat.st_size
allocated_size = int(getattr(root_stat, "st_blocks", 0) * 512)
if root_stat.st_nlink > 1:
hardlinked_files = 1
hardlinked_apparent_size = root_stat.st_size
else:
for current_root, dirnames, filenames in path.walk():
directories += len(dirnames)
for filename in filenames:
file_path = current_root / filename
try:
file_stat = file_path.lstat()
except OSError:
continue
files += 1
apparent_size += file_stat.st_size
inode_key = (file_stat.st_dev, file_stat.st_ino)
if inode_key not in seen_allocated_inodes:
allocated_size += int(getattr(file_stat, "st_blocks", 0) * 512)
seen_allocated_inodes.add(inode_key)
if file_stat.st_nlink > 1:
hardlinked_files += 1
hardlinked_apparent_size += file_stat.st_size
return {
"path": str(path),
"apparent_size_bytes": int(apparent_size),
"allocated_size_bytes": int(allocated_size),
"files": files,
"directories": directories,
"hardlinked_files": hardlinked_files,
"hardlinked_apparent_size_bytes": int(hardlinked_apparent_size),
"hardlink_apparent_ratio": round(hardlinked_apparent_size / apparent_size, 4) if apparent_size else 0.0,
}
def _parse_colon_stat(line: str, stats: dict[str, Any]) -> None:
if ":" not in line:
return
label, value = line.split(":", 1)
label = label.strip()
value = value.strip()
if label in _COUNT_KEYS:
parsed = _parse_int_prefix(value)
if parsed is not None:
stats[_COUNT_KEYS[label]] = parsed
elif label in _BYTE_KEYS:
parsed = _parse_byte_value(value)
if parsed is not None:
stats[_BYTE_KEYS[label]] = parsed
def _parse_sent_received(line: str, stats: dict[str, Any]) -> None:
match = re.search(
r"sent\s+(?P<sent>[\d,]+(?:\.\d+)?\s*[A-Za-z]*)\s+bytes\s+received\s+"
r"(?P<received>[\d,]+(?:\.\d+)?\s*[A-Za-z]*)\s+bytes\s+"
r"(?P<rate>[\d,]+(?:\.\d+)?\s*[A-Za-z]*)\s+bytes/sec",
line,
)
if not match:
return
sent = _parse_byte_value(match.group("sent"))
received = _parse_byte_value(match.group("received"))
rate = _parse_byte_value(match.group("rate"))
if sent is not None:
stats["bytes_sent"] = sent
if received is not None:
stats["bytes_received"] = received
if rate is not None:
stats["bytes_per_second"] = rate
def _parse_total_size_speedup(line: str, stats: dict[str, Any]) -> None:
match = re.search(
r"total size is\s+(?P<size>[\d,]+(?:\.\d+)?\s*[A-Za-z]*)\s+speedup is\s+(?P<speedup>[\d,]+(?:\.\d+)?)",
line,
)
if not match:
return
total_size = _parse_byte_value(match.group("size"))
if total_size is not None:
stats["total_file_size_bytes"] = total_size
stats["speedup"] = float(match.group("speedup").replace(",", ""))
def _add_derived_stats(stats: dict[str, Any]) -> None:
sent = stats.get("bytes_sent")
received = stats.get("bytes_received")
if isinstance(sent, int) and isinstance(received, int):
stats["bytes_sent_received"] = sent + received
literal = stats.get("literal_data_bytes")
matched = stats.get("matched_data_bytes")
if isinstance(literal, int) and isinstance(matched, int):
basis_total = literal + matched
stats["link_dest_estimated_savings_bytes"] = matched
stats["link_dest_estimated_savings_ratio"] = round(matched / basis_total, 4) if basis_total else 0.0
def _parse_int_prefix(value: str) -> int | None:
match = re.match(r"([\d,]+)", value)
if not match:
return None
return int(match.group(1).replace(",", ""))
def _parse_byte_value(value: str) -> int | None:
match = re.match(r"([\d,]+(?:\.\d+)?)\s*([A-Za-z]*)", value.strip())
if not match:
return None
number = float(match.group(1).replace(",", ""))
unit = match.group(2).lower()
multiplier = _SIZE_UNITS.get(unit)
if multiplier is None:
return int(number)
return int(number * multiplier)

View File

@@ -0,0 +1,98 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
from .errors import ConfigError
@dataclass(frozen=True)
class SnapshotRef:
host: str
kind: str # scheduled | manual | incomplete
dirname: str
path: Path
def snapshot_meta_path(snapshot_dir: Path) -> Path:
return snapshot_dir / "meta" / "meta.yaml"
def snapshot_log_path(snapshot_dir: Path) -> Path:
return snapshot_dir / "meta" / "rsync.log"
def read_snapshot_meta(snapshot_dir: Path) -> dict[str, Any]:
"""
Read meta/meta.yaml for a snapshot directory.
Returns {} if missing/unreadable YAML; callers can decide how strict to be.
"""
import yaml # type: ignore[import-not-found]
p = snapshot_meta_path(snapshot_dir)
if not p.exists():
return {}
try:
data = yaml.safe_load(p.read_text(encoding="utf-8"))
if data is None:
return {}
if not isinstance(data, dict):
return {}
return data
except OSError:
return {}
except Exception:
# YAML parse errors should not crash listing; return empty meta.
return {}
def iter_snapshot_dirs(host_root: Path, kind: str) -> Iterable[Path]:
"""
Yield snapshot directories for a given host root and kind.
kind: scheduled|manual|incomplete
"""
if kind == "scheduled":
parent = host_root / "scheduled"
elif kind == "manual":
parent = host_root / "manual"
elif kind == "incomplete":
parent = host_root / ".incomplete"
else:
raise ValueError(f"Invalid kind: {kind!r}")
if not parent.exists():
return []
# Snapshot dirs are named <ts>__<id> and sorted lexicographically == chronological
dirs = [p for p in parent.iterdir() if p.is_dir()]
dirs.sort(reverse=True)
return dirs
def build_snapshot_ref(host: str, host_root: Path, kind: str, dirname: str) -> SnapshotRef:
if kind == "scheduled":
p = host_root / "scheduled" / dirname
elif kind == "manual":
p = host_root / "manual" / dirname
elif kind == "incomplete":
p = host_root / ".incomplete" / dirname
else:
raise ValueError(f"Invalid kind: {kind!r}")
return SnapshotRef(host=host, kind=kind, dirname=dirname, path=p)
def resolve_host_root(backup_root: str, host: str) -> Path:
if not backup_root.startswith("/"):
raise ConfigError("backup_root must be an absolute path")
return Path(backup_root) / host
def normalize_kind(kind: str) -> str:
k = kind.strip().lower()
if k in {"scheduled", "manual", "incomplete", "all"}:
return k
raise ConfigError("kind must be one of: scheduled, manual, incomplete, all")

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import os
import re
import tempfile
from pathlib import Path
from typing import Any
@@ -25,7 +26,6 @@ def sanitize_host(host: str) -> str:
return host
def ensure_dir(path: Path, mode: int = 0o750) -> None:
path.mkdir(parents=True, exist_ok=True)
try:
@@ -62,3 +62,114 @@ def to_json_safe(obj: Any) -> Any:
return obj
return str(obj)
def write_yaml_atomic(path: Path, data: Any) -> None:
"""
Write YAML to `path` atomically.
Strategy:
- Write to a temp file in the same directory
- fsync temp file
- os.replace(temp, path) (atomic on POSIX)
- fsync directory entry (best-effort)
This helps avoid partial/corrupt meta files on crashes.
"""
# Local import to keep module load light; PyYAML is already a project dependency.
import yaml # type: ignore[import-not-found]
parent = path.parent
parent.mkdir(parents=True, exist_ok=True)
tmp_fd: int | None = None
tmp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=str(parent),
prefix=path.name + ".",
suffix=".tmp",
delete=False,
) as tf:
tmp_fd = tf.fileno()
tmp_path = Path(tf.name)
tf.write(yaml.safe_dump(data, sort_keys=False))
tf.flush()
os.fsync(tmp_fd)
os.replace(str(tmp_path), str(path))
# Best-effort directory fsync (helps durability across power loss on some FS)
try:
dir_fd = os.open(str(parent), os.O_DIRECTORY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except OSError:
pass
finally:
# If anything failed before replace(), try to clean up temp file
if tmp_path is not None and tmp_path.exists():
try:
tmp_path.unlink()
except OSError:
pass
def write_text_atomic(path: Path, content: str) -> None:
"""
Write text to `path` atomically.
Strategy:
- Write to a temp file in the same directory
- fsync temp file
- os.replace(temp, path) (atomic on POSIX)
- fsync directory entry (best-effort)
This helps avoid partial/corrupt files on crashes.
"""
parent = path.parent
parent.mkdir(parents=True, exist_ok=True)
tmp_fd: int | None = None
tmp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=str(parent),
prefix=path.name + ".",
suffix=".tmp",
delete=False,
) as tf:
tmp_fd = tf.fileno()
tmp_path = Path(tf.name)
tf.write(content)
tf.flush()
os.fsync(tmp_fd)
os.replace(str(tmp_path), str(path))
# Best-effort directory fsync (helps durability across power loss on some FS)
try:
dir_fd = os.open(str(parent), os.O_DIRECTORY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except OSError:
pass
finally:
# If anything failed before replace(), try to clean up temp file
if tmp_path is not None and tmp_path.exists():
try:
tmp_path.unlink()
except OSError:
pass

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from collections.abc import Callable
from functools import wraps
from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.http import HttpRequest, HttpResponse
def can_view_status(user) -> bool:
return bool(user.is_authenticated)
def can_manage_control_panel(user) -> bool:
return bool(user.is_authenticated and user.is_staff)
def status_view_required(view_func: Callable[..., HttpResponse]) -> Callable[..., HttpResponse]:
return login_required(view_func)
def control_panel_admin_required(view_func: Callable[..., HttpResponse]) -> Callable[..., HttpResponse]:
@login_required
@wraps(view_func)
def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if not can_manage_control_panel(request.user):
raise PermissionDenied
return view_func(request, *args, **kwargs)
return wrapper
def access_context(request: HttpRequest) -> dict[str, Any]:
return {
"can_view_status": can_view_status(request.user),
"can_manage_control_panel": can_manage_control_panel(request.user),
}

View File

@@ -0,0 +1,238 @@
from __future__ import annotations
from django.contrib import admin
from django.db.models import Count
from django.urls import reverse
from django.utils.html import format_html
from django.utils.http import urlencode
from .models import (
BackupRun,
GlobalConfig,
HostConfig,
NotificationDelivery,
NotificationTarget,
PurgedSnapshot,
ScheduleConfig,
SnapshotRecord,
SshCredential,
)
@admin.register(SshCredential)
class SshCredentialAdmin(admin.ModelAdmin):
list_display = ("name", "key_type", "generated", "has_public_key", "has_known_hosts", "updated_at")
readonly_fields = ("created_at", "updated_at", "fingerprint")
search_fields = ("name", "notes")
fieldsets = (
(None, {"fields": ("name", "key_type", "generated", "key_path", "fingerprint")}),
("Key material", {"fields": ("private_key", "public_key", "known_hosts", "notes")}),
("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
)
@admin.display(boolean=True, description="Public key")
def has_public_key(self, obj: SshCredential) -> bool:
return bool(obj.public_key.strip())
@admin.display(boolean=True, description="Known hosts")
def has_known_hosts(self, obj: SshCredential) -> bool:
return bool(obj.known_hosts.strip())
@admin.register(GlobalConfig)
class GlobalConfigAdmin(admin.ModelAdmin):
list_display = ("name", "backup_root", "ssh_user", "ssh_port", "updated_at")
readonly_fields = ("created_at", "updated_at")
fieldsets = (
(None, {"fields": ("name", "backup_root")}),
("SSH", {"fields": ("default_ssh_credential", "ssh_user", "ssh_port", "ssh_options")}),
(
"Rsync",
{
"fields": (
"rsync_binary",
"rsync_args",
"rsync_extra_args",
"rsync_timeout_seconds",
"rsync_bwlimit_kbps",
)
},
),
("Defaults", {"fields": ("default_source_root", "default_destination_subdir", "excludes_default")}),
("Retention defaults", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}),
("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
)
@admin.register(HostConfig)
class HostConfigAdmin(admin.ModelAdmin):
list_display = (
"host",
"address",
"enabled",
"schedule_state",
"snapshot_count_link",
"backup_run_count_link",
"latest_run_state",
"updated_at",
)
list_filter = ("enabled",)
search_fields = ("host", "address")
readonly_fields = ("created_at", "updated_at")
fieldsets = (
(None, {"fields": ("host", "address", "enabled")}),
("SSH override", {"fields": ("ssh_credential", "ssh_user", "ssh_port")}),
("Source", {"fields": ("source_root", "includes", "excludes_add", "excludes_replace")}),
("Rsync override", {"fields": ("rsync_extra_args", "rsync_bwlimit_kbps")}),
("Retention", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}),
("Runtime state", {"fields": ("config",), "classes": ("collapse",)}),
("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
)
def get_queryset(self, request):
return super().get_queryset(request).annotate(
snapshot_count=Count("snapshots", distinct=True),
backup_run_count=Count("runs", distinct=True),
)
@admin.display(description="Schedule")
def schedule_state(self, obj: HostConfig) -> str:
try:
schedule = obj.schedule
except ScheduleConfig.DoesNotExist:
return "none"
if not schedule.enabled:
return "disabled"
return schedule.cron_expr
@admin.display(description="Snapshots", ordering="snapshot_count")
def snapshot_count_link(self, obj: HostConfig) -> str:
count = getattr(obj, "snapshot_count", None)
if count is None:
count = obj.snapshots.count()
url = _admin_changelist_url("pobsync_backend", "snapshotrecord", {"host__id__exact": obj.pk})
return format_html('<a href="{}">{}</a>', url, count)
@admin.display(description="Runs", ordering="backup_run_count")
def backup_run_count_link(self, obj: HostConfig) -> str:
count = getattr(obj, "backup_run_count", None)
if count is None:
count = obj.runs.count()
url = _admin_changelist_url("pobsync_backend", "backuprun", {"host__id__exact": obj.pk})
return format_html('<a href="{}">{}</a>', url, count)
@admin.display(description="Latest run")
def latest_run_state(self, obj: HostConfig) -> str:
latest = obj.runs.order_by("-created_at").first()
if latest is None:
return "none"
return f"{latest.status} {latest.started_at:%Y-%m-%d %H:%M}" if latest.started_at else latest.status
@admin.register(BackupRun)
class BackupRunAdmin(admin.ModelAdmin):
list_display = ("host", "run_type", "status", "started_at", "ended_at", "snapshot_link")
list_filter = ("run_type", "status", "started_at")
search_fields = ("host__host", "snapshot_path", "snapshot__dirname", "snapshot__path")
autocomplete_fields = ("snapshot",)
list_select_related = ("host", "snapshot")
date_hierarchy = "started_at"
@admin.display(description="Snapshot", ordering="snapshot__dirname")
def snapshot_link(self, obj: BackupRun) -> str:
if obj.snapshot is None:
return ""
url = reverse("admin:pobsync_backend_snapshotrecord_change", args=[obj.snapshot.pk])
return format_html('<a href="{}">{}</a>', url, obj.snapshot.dirname)
@admin.register(NotificationTarget)
class NotificationTargetAdmin(admin.ModelAdmin):
list_display = ("name", "channel", "enabled", "last_status", "last_sent_at", "updated_at")
list_filter = ("enabled", "channel", "last_status")
search_fields = ("name", "email_to", "webhook_url", "notes")
readonly_fields = ("created_at", "updated_at", "last_status", "last_error", "last_sent_at")
fieldsets = (
(None, {"fields": ("name", "enabled", "channel", "statuses")}),
("Email", {"fields": ("email_to",)}),
("Webhook", {"fields": ("webhook_url", "webhook_headers")}),
("State", {"fields": ("last_status", "last_error", "last_sent_at"), "classes": ("collapse",)}),
("Notes", {"fields": ("notes",), "classes": ("collapse",)}),
("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
)
@admin.register(NotificationDelivery)
class NotificationDeliveryAdmin(admin.ModelAdmin):
list_display = ("target", "run", "status", "created_at")
list_filter = ("status", "target__channel", "created_at")
search_fields = ("target__name", "run__host__host", "error")
readonly_fields = ("target", "run", "status", "error", "payload", "created_at")
list_select_related = ("target", "run", "run__host")
date_hierarchy = "created_at"
def has_add_permission(self, request) -> bool:
return False
def has_change_permission(self, request, obj=None) -> bool:
return False
@admin.register(SnapshotRecord)
class SnapshotRecordAdmin(admin.ModelAdmin):
list_display = ("host", "kind", "dirname", "status", "base_link", "backup_run_count_link", "started_at", "discovered_at")
list_filter = ("kind", "status", "base_kind", "started_at", "discovered_at")
search_fields = (
"host__host",
"dirname",
"path",
"base__dirname",
"base_path",
"base_snapshot_id",
)
autocomplete_fields = ("base",)
list_select_related = ("host", "base")
readonly_fields = ("discovered_at",)
def get_queryset(self, request):
return super().get_queryset(request).annotate(backup_run_count=Count("backup_runs"))
@admin.display(description="Base", ordering="base__dirname")
def base_link(self, obj: SnapshotRecord) -> str:
if obj.base is not None:
url = reverse("admin:pobsync_backend_snapshotrecord_change", args=[obj.base.pk])
return format_html('<a href="{}">{}</a>', url, obj.base.dirname)
if obj.base_dirname:
return obj.base_dirname
return ""
@admin.display(description="Runs", ordering="backup_run_count")
def backup_run_count_link(self, obj: SnapshotRecord) -> str:
count = getattr(obj, "backup_run_count", None)
if count is None:
count = obj.backup_runs.count()
url = _admin_changelist_url("pobsync_backend", "backuprun", {"snapshot__id__exact": obj.pk})
return format_html('<a href="{}">{}</a>', url, count)
@admin.register(PurgedSnapshot)
class PurgedSnapshotAdmin(admin.ModelAdmin):
list_display = ("host_name", "kind", "dirname", "action", "reason", "triggered_by", "purged_at")
list_filter = ("action", "kind", "purged_at")
search_fields = ("host_name", "dirname", "path", "reason", "triggered_by")
list_select_related = ("host",)
readonly_fields = ("purged_at",)
date_hierarchy = "purged_at"
@admin.register(ScheduleConfig)
class ScheduleConfigAdmin(admin.ModelAdmin):
list_display = ("host", "cron_expr", "enabled", "prune", "last_status", "last_started_at", "updated_at")
list_filter = ("enabled", "prune", "last_status")
search_fields = ("host__host", "cron_expr")
list_select_related = ("host",)
def _admin_changelist_url(app_label: str, model_name: str, params: dict[str, object]) -> str:
base_url = reverse(f"admin:{app_label}_{model_name}_changelist")
return f"{base_url}?{urlencode(params)}"

194
src/pobsync_backend/api.py Normal file
View File

@@ -0,0 +1,194 @@
from __future__ import annotations
from typing import Any
from django.db import connection
from django.db.models import Count
from django.http import JsonResponse
from django.utils import timezone
from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord
from .access import control_panel_admin_required, status_view_required
@control_panel_admin_required
def api_index(request) -> JsonResponse:
return JsonResponse(
{
"ok": True,
"endpoints": {
"status": request.build_absolute_uri("/api/status/"),
"hosts": request.build_absolute_uri("/api/hosts/"),
"snapshots": request.build_absolute_uri("/api/snapshots/"),
"runs": request.build_absolute_uri("/api/runs/"),
},
}
)
@status_view_required
def status(request) -> JsonResponse:
latest_run = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at").first()
latest_schedule = ScheduleConfig.objects.select_related("host").order_by("-last_started_at", "-updated_at").first()
return JsonResponse(
{
"ok": True,
"generated_at": timezone.now().isoformat(),
"database": {
"vendor": connection.vendor,
"engine": connection.settings_dict["ENGINE"],
},
"counts": {
"hosts": HostConfig.objects.count(),
"enabled_hosts": HostConfig.objects.filter(enabled=True).count(),
"schedules": ScheduleConfig.objects.count(),
"enabled_schedules": ScheduleConfig.objects.filter(enabled=True).count(),
"snapshots": SnapshotRecord.objects.count(),
"runs": BackupRun.objects.count(),
"running_runs": BackupRun.objects.filter(status=BackupRun.Status.RUNNING).count(),
"failed_runs": BackupRun.objects.filter(status=BackupRun.Status.FAILED).count(),
},
"latest_run": None if latest_run is None else _run_payload(latest_run),
"latest_schedule": None if latest_schedule is None else _schedule_payload(latest_schedule),
}
)
@control_panel_admin_required
def hosts(request) -> JsonResponse:
host_qs = (
HostConfig.objects.annotate(snapshot_count=Count("snapshots", distinct=True), run_count=Count("runs", distinct=True))
.select_related("schedule")
.order_by("host")
)
return JsonResponse({"ok": True, "hosts": [_host_payload(host) for host in host_qs]})
@control_panel_admin_required
def snapshots(request) -> JsonResponse:
snapshot_qs = SnapshotRecord.objects.select_related("host", "base").order_by("host__host", "-started_at", "dirname")
host_filter = request.GET.get("host")
kind_filter = request.GET.get("kind")
if host_filter:
snapshot_qs = snapshot_qs.filter(host__host=host_filter)
if kind_filter:
snapshot_qs = snapshot_qs.filter(kind=kind_filter)
limit = _limit_from_request(request)
return JsonResponse({"ok": True, "snapshots": [_snapshot_payload(snapshot) for snapshot in snapshot_qs[:limit]]})
@control_panel_admin_required
def runs(request) -> JsonResponse:
run_qs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")
host_filter = request.GET.get("host")
status_filter = request.GET.get("status")
if host_filter:
run_qs = run_qs.filter(host__host=host_filter)
if status_filter:
run_qs = run_qs.filter(status=status_filter)
limit = _limit_from_request(request)
return JsonResponse({"ok": True, "runs": [_run_payload(run) for run in run_qs[:limit]]})
def _host_payload(host: HostConfig) -> dict[str, Any]:
try:
schedule = host.schedule
except ScheduleConfig.DoesNotExist:
schedule = None
return {
"host": host.host,
"address": host.address,
"enabled": host.enabled,
"snapshot_count": host.snapshot_count,
"run_count": host.run_count,
"retention": {
"daily": host.retention_daily,
"weekly": host.retention_weekly,
"monthly": host.retention_monthly,
"yearly": host.retention_yearly,
},
"schedule": None
if schedule is None
else _schedule_payload(schedule),
}
def _snapshot_payload(snapshot: SnapshotRecord) -> dict[str, Any]:
return {
"host": snapshot.host.host,
"kind": snapshot.kind,
"dirname": snapshot.dirname,
"path": snapshot.path,
"status": snapshot.status,
"started_at": _iso(snapshot.started_at),
"ended_at": _iso(snapshot.ended_at),
"discovered_at": _iso(snapshot.discovered_at),
"base": _base_payload(snapshot),
}
def _base_payload(snapshot: SnapshotRecord) -> dict[str, Any] | None:
if snapshot.base is not None:
return {
"kind": snapshot.base.kind,
"dirname": snapshot.base.dirname,
"path": snapshot.base.path,
"resolved": True,
}
if snapshot.base_kind and snapshot.base_dirname:
return {
"kind": snapshot.base_kind,
"dirname": snapshot.base_dirname,
"path": snapshot.base_path,
"snapshot_id": snapshot.base_snapshot_id,
"resolved": False,
}
return None
def _run_payload(run: BackupRun) -> dict[str, Any]:
return {
"id": run.pk,
"host": run.host.host,
"run_type": run.run_type,
"status": run.status,
"started_at": _iso(run.started_at),
"ended_at": _iso(run.ended_at),
"snapshot": None
if run.snapshot is None
else {
"kind": run.snapshot.kind,
"dirname": run.snapshot.dirname,
"path": run.snapshot.path,
},
"snapshot_path": run.snapshot_path,
"base_path": run.base_path,
"rsync_exit_code": run.rsync_exit_code,
}
def _schedule_payload(schedule: ScheduleConfig) -> dict[str, Any]:
return {
"host": schedule.host.host,
"cron_expr": schedule.cron_expr,
"enabled": schedule.enabled,
"prune": schedule.prune,
"last_due_key": schedule.last_due_key,
"last_status": schedule.last_status,
"last_started_at": _iso(schedule.last_started_at),
"last_finished_at": _iso(schedule.last_finished_at),
}
def _limit_from_request(request, *, default: int = 100, maximum: int = 500) -> int:
value = request.GET.get("limit", str(default))
try:
limit = int(value)
except ValueError:
return default
return max(1, min(limit, maximum))
def _iso(value) -> str | None:
return value.isoformat() if value is not None else None

View File

@@ -0,0 +1,9 @@
from __future__ import annotations
from django.apps import AppConfig
class PobsyncBackendConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "pobsync_backend"
verbose_name = "Pobsync backend"

View File

@@ -0,0 +1,486 @@
from __future__ import annotations
import os
import socket
from datetime import timedelta, timezone as datetime_timezone
from pathlib import Path
from django.db import transaction
from django.utils import timezone
from pobsync.commands.run_scheduled import (
DEFAULT_DRY_RUN_TIMEOUT_SECONDS,
classify_rsync_failure,
classify_rsync_warning,
dry_run_log_path,
run_scheduled,
)
from pobsync_backend.config_source import DjangoConfigSource
from pobsync_backend.models import BackupRun, HostConfig
from pobsync_backend.notifications import notify_backup_run_completed
from pobsync_backend.retention import run_sql_retention_apply
from pobsync_backend.snapshot_discovery import infer_snapshot_kind, upsert_snapshot_record
def queue_backup_run(
*,
host: HostConfig,
run_type: str = BackupRun.RunType.MANUAL,
dry_run: bool = False,
verbose_output: bool = True,
prune: bool = False,
prune_max_delete: int = 10,
prune_protect_bases: bool = False,
) -> BackupRun:
return BackupRun.objects.create(
host=host,
run_type=run_type,
status=BackupRun.Status.QUEUED,
result={
"requested": {
"dry_run": bool(dry_run),
"verbose_output": bool(dry_run or verbose_output),
"prune": bool(prune),
"prune_max_delete": int(prune_max_delete),
"prune_protect_bases": bool(prune_protect_bases),
}
},
)
def execute_backup_run(
*,
run: BackupRun,
prefix: Path,
dry_run: bool = False,
verbose_output: bool = False,
prune: bool = False,
prune_max_delete: int = 10,
prune_protect_bases: bool = False,
) -> BackupRun:
run.status = BackupRun.Status.RUNNING
run.started_at = run.started_at or timezone.now()
run.result = _running_result(run=run, dry_run=bool(dry_run))
run.save(update_fields=["status", "started_at", "result"])
try:
result = run_scheduled(
prefix=prefix,
host=run.host.host,
dry_run=bool(dry_run),
prune=False,
config_source=DjangoConfigSource(),
run_id=run.id,
cancel_check=lambda: _run_cancel_requested(run.id),
verbose_output=bool(dry_run or verbose_output),
state_callback=lambda state: _record_running_state(run.id, state),
)
except Exception as exc:
run.refresh_from_db()
run.status = BackupRun.Status.CANCELLED if run.status == BackupRun.Status.CANCELLED else BackupRun.Status.FAILED
run.ended_at = timezone.now()
run.result = {
**(run.result if isinstance(run.result, dict) else {}),
"ok": False,
"error": str(exc),
"type": type(exc).__name__,
}
run.save(update_fields=["status", "ended_at", "result"])
notify_backup_run_completed(run)
raise
run.refresh_from_db()
if result.get("cancelled") or run.status == BackupRun.Status.CANCELLED:
run.status = BackupRun.Status.CANCELLED
elif result.get("status") == BackupRun.Status.WARNING:
run.status = BackupRun.Status.WARNING
else:
run.status = BackupRun.Status.SUCCESS if result.get("ok") else BackupRun.Status.FAILED
run.ended_at = timezone.now()
run.snapshot_path = str(result.get("snapshot") or "")
run.base_path = str(result.get("base") or "")
rsync = result.get("rsync") if isinstance(result.get("rsync"), dict) else {}
run.rsync_exit_code = rsync.get("exit_code")
run.result = result
snapshot_record = None
if run.snapshot_path:
snapshot_path = Path(run.snapshot_path)
try:
kind = infer_snapshot_kind(snapshot_path)
snapshot_record, _created = upsert_snapshot_record(host=run.host, kind=kind, snapshot_dir=snapshot_path)
except ValueError:
snapshot_record = None
if result.get("ok") and not result.get("dry_run") and prune:
try:
result["prune"] = run_sql_retention_apply(
prefix=prefix,
host=run.host.host,
kind="scheduled",
protect_bases=bool(prune_protect_bases),
yes=True,
max_delete=int(prune_max_delete),
action=run.run_type,
acquire_lock=False,
)
except Exception as exc:
result["prune"] = {"ok": False, "error": str(exc), "type": type(exc).__name__}
run.status = BackupRun.Status.WARNING
run.result = result
run.snapshot = snapshot_record
run.save(
update_fields=[
"status",
"ended_at",
"snapshot_path",
"snapshot",
"base_path",
"rsync_exit_code",
"result",
],
)
run.snapshot = snapshot_record
run.result = result
run.save(
update_fields=[
"status",
"ended_at",
"snapshot_path",
"snapshot",
"base_path",
"rsync_exit_code",
"result",
],
)
notify_backup_run_completed(run)
return run
def claim_next_queued_run() -> BackupRun | None:
with transaction.atomic():
run = (
BackupRun.objects.select_related("host")
.filter(status=BackupRun.Status.QUEUED, host__enabled=True)
.order_by("created_at", "id")
.first()
)
if run is None:
return None
run.status = BackupRun.Status.RUNNING
run.started_at = timezone.now()
run.save(update_fields=["status", "started_at"])
return run
def reconcile_running_runs(*, grace_seconds: int = 300, stale_worker_seconds: int = 24 * 60 * 60) -> int:
reconciled = 0
for run in BackupRun.objects.select_related("host").filter(status=BackupRun.Status.RUNNING).order_by("started_at", "id"):
if _reconcile_running_run(run=run, grace_seconds=grace_seconds, stale_worker_seconds=stale_worker_seconds):
reconciled += 1
return reconciled
def requested_options(run: BackupRun) -> dict[str, object]:
requested = run.result.get("requested") if isinstance(run.result, dict) else None
if not isinstance(requested, dict):
return {}
return requested
def _running_result(*, run: BackupRun, dry_run: bool) -> dict[str, object]:
result = dict(run.result) if isinstance(run.result, dict) else {}
execution = {
**_worker_execution_details(),
"started_at": (run.started_at or timezone.now()).isoformat(),
"heartbeat_at": timezone.now().isoformat(),
}
if dry_run:
execution["log"] = str(dry_run_log_path(run.host.host, run_id=run.id))
result["execution"] = execution
return result
def _run_cancel_requested(run_id: int) -> bool:
try:
run = BackupRun.objects.only("id", "status", "result").get(id=run_id)
except BackupRun.DoesNotExist:
return True
if run.status == BackupRun.Status.CANCELLED:
return True
if run.status == BackupRun.Status.RUNNING:
_refresh_run_heartbeat(run)
return False
def _record_running_state(run_id: int, state: dict[str, object]) -> None:
try:
run = BackupRun.objects.only("id", "status", "result", "snapshot_path", "rsync_exit_code").get(id=run_id)
except BackupRun.DoesNotExist:
return
if run.status != BackupRun.Status.RUNNING:
return
result = run.result if isinstance(run.result, dict) else {}
execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
rsync = result.get("rsync") if isinstance(result.get("rsync"), dict) else {}
incoming_rsync = state.get("rsync") if isinstance(state.get("rsync"), dict) else {}
log_path = state.get("log")
snapshot_path = state.get("snapshot")
phase = state.get("phase")
if isinstance(phase, str) and phase:
execution["phase"] = phase
if isinstance(log_path, str) and log_path:
execution["log"] = log_path
if isinstance(snapshot_path, str) and snapshot_path:
execution["snapshot"] = snapshot_path
run.snapshot_path = snapshot_path
if incoming_rsync:
result["rsync"] = {**rsync, **incoming_rsync}
exit_code = incoming_rsync.get("exit_code")
if isinstance(exit_code, int):
run.rsync_exit_code = exit_code
result["execution"] = {
**execution,
"worker_pid": os.getpid(),
"worker_host": socket.gethostname(),
"heartbeat_at": timezone.now().isoformat(),
}
run.result = result
run.save(update_fields=["snapshot_path", "rsync_exit_code", "result"])
def _reconcile_running_run(*, run: BackupRun, grace_seconds: int, stale_worker_seconds: int) -> bool:
result = run.result if isinstance(run.result, dict) else {}
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
log_path = _execution_log_path(result)
log_tail = _read_log_tail(log_path) if log_path is not None else []
terminal_log = _terminal_rsync_log(log_tail)
exit_code = _exit_code_from_log(log_tail)
stale_worker = _running_worker_timed_out(run=run, stale_worker_seconds=stale_worker_seconds)
if not requested.get("dry_run"):
if terminal_log:
failure = classify_rsync_failure(exit_code or 255, log_tail)
result.update(
{
"ok": False,
"host": run.host.host,
"log": str(log_path) if log_path else "",
"failure": failure,
"rsync": {
**(result.get("rsync") if isinstance(result.get("rsync"), dict) else {}),
"exit_code": exit_code or 255,
"log_tail": log_tail,
},
}
)
run.status = BackupRun.Status.FAILED
run.ended_at = timezone.now()
run.rsync_exit_code = exit_code or 255
run.result = result
run.save(update_fields=["status", "ended_at", "rsync_exit_code", "result"])
notify_backup_run_completed(run)
return True
if _running_rsync_process_missing(run=run, grace_seconds=grace_seconds):
result.update(
{
"ok": False,
"host": run.host.host,
"log": str(log_path) if log_path else "",
"failure": {
"category": "rsync_process",
"message": "The rsync process is no longer running while the backup is still marked running.",
"hint": "Check the rsync log and pobsync-worker.service logs before retrying the backup.",
},
"rsync": {
**(result.get("rsync") if isinstance(result.get("rsync"), dict) else {}),
"exit_code": exit_code or 255,
"log_tail": log_tail,
},
}
)
run.status = BackupRun.Status.FAILED
run.ended_at = timezone.now()
run.rsync_exit_code = exit_code or 255
run.result = result
run.save(update_fields=["status", "ended_at", "rsync_exit_code", "result"])
notify_backup_run_completed(run)
return True
if stale_worker:
result.update(
{
"ok": False,
"host": run.host.host,
"failure": {
"category": "worker",
"message": "The worker heartbeat stopped before the run finished.",
"hint": "Check pobsync-worker.service logs before retrying the backup.",
},
}
)
run.status = BackupRun.Status.FAILED
run.ended_at = timezone.now()
run.result = result
run.save(update_fields=["status", "ended_at", "result"])
notify_backup_run_completed(run)
return True
return False
timed_out = _running_dry_run_timed_out(run=run, grace_seconds=grace_seconds)
if not terminal_log and not timed_out and not stale_worker:
return False
exit_code = exit_code or (124 if timed_out or stale_worker else 255)
failure = classify_rsync_failure(exit_code, log_tail)
if stale_worker and not terminal_log:
failure = {
"category": "worker",
"message": "The worker heartbeat stopped before the dry-run finished.",
"hint": "Check pobsync-worker.service logs before retrying the dry-run.",
}
result.update(
{
"ok": False,
"dry_run": True,
"host": run.host.host,
"base": result.get("base"),
"log": str(log_path) if log_path else "",
"failure": failure,
"rsync": {
**(result.get("rsync") if isinstance(result.get("rsync"), dict) else {}),
"exit_code": exit_code,
"log_tail": log_tail,
},
}
)
run.status = BackupRun.Status.FAILED
run.ended_at = timezone.now()
run.rsync_exit_code = exit_code
run.result = result
run.save(update_fields=["status", "ended_at", "rsync_exit_code", "result"])
notify_backup_run_completed(run)
return True
def _worker_execution_details() -> dict[str, object]:
return {
"worker_pid": os.getpid(),
"worker_host": socket.gethostname(),
"claimed_at": timezone.now().isoformat(),
}
def _refresh_run_heartbeat(run: BackupRun, *, interval_seconds: int = 30) -> None:
result = run.result if isinstance(run.result, dict) else {}
execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
heartbeat_at = _parse_iso_datetime(execution.get("heartbeat_at"))
if heartbeat_at is not None and timezone.now() < heartbeat_at + timedelta(seconds=interval_seconds):
return
result["execution"] = {
**execution,
"worker_pid": os.getpid(),
"worker_host": socket.gethostname(),
"heartbeat_at": timezone.now().isoformat(),
}
run.result = result
run.save(update_fields=["result"])
def _execution_log_path(result: dict[str, object]) -> Path | None:
execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
log = execution.get("log") or result.get("log")
if not isinstance(log, str) or not log:
return None
return Path(log)
def _read_log_tail(log_path: Path | None, *, max_lines: int = 40) -> list[str]:
if log_path is None:
return []
try:
return log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-max_lines:]
except OSError:
return []
def _terminal_rsync_log(log_tail: list[str]) -> bool:
warning = classify_rsync_warning(_exit_code_from_log(log_tail), log_tail)
if warning is not None:
return False
return any(line.startswith("rsync error:") for line in log_tail)
def _exit_code_from_log(log_tail: list[str]) -> int | None:
for line in reversed(log_tail):
if "code 255" in line:
return 255
if "code 24" in line:
return 24
if "code 124" in line:
return 124
if "code 12" in line:
return 12
return None
def _running_dry_run_timed_out(*, run: BackupRun, grace_seconds: int) -> bool:
if run.started_at is None:
return False
result = run.result if isinstance(run.result, dict) else {}
timeout_seconds = result.get("timeout_seconds")
if not isinstance(timeout_seconds, int) or timeout_seconds <= 0:
timeout_seconds = DEFAULT_DRY_RUN_TIMEOUT_SECONDS
return timezone.now() >= run.started_at + timedelta(seconds=timeout_seconds + grace_seconds)
def _running_worker_timed_out(*, run: BackupRun, stale_worker_seconds: int) -> bool:
if stale_worker_seconds <= 0:
return False
result = run.result if isinstance(run.result, dict) else {}
execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
heartbeat_at = _parse_iso_datetime(execution.get("heartbeat_at"))
if heartbeat_at is None:
heartbeat_at = run.started_at
if heartbeat_at is None:
return False
return timezone.now() >= heartbeat_at + timedelta(seconds=stale_worker_seconds)
def _running_rsync_process_missing(*, run: BackupRun, grace_seconds: int) -> bool:
if grace_seconds <= 0:
return False
result = run.result if isinstance(run.result, dict) else {}
execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
if execution.get("phase") != "rsync":
return False
rsync = result.get("rsync") if isinstance(result.get("rsync"), dict) else {}
pid = rsync.get("pid")
if not isinstance(pid, int) or pid <= 0:
return False
heartbeat_at = _parse_iso_datetime(execution.get("heartbeat_at")) or run.started_at
if heartbeat_at is None or timezone.now() < heartbeat_at + timedelta(seconds=grace_seconds):
return False
return not _process_exists(pid)
def _process_exists(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def _parse_iso_datetime(value: object):
if not isinstance(value, str) or not value:
return None
try:
parsed = timezone.datetime.fromisoformat(value)
except ValueError:
return None
if timezone.is_naive(parsed):
return timezone.make_aware(parsed, timezone=datetime_timezone.utc)
return parsed

View File

@@ -0,0 +1,201 @@
from __future__ import annotations
import os
import shutil
from pathlib import Path
from django.conf import settings
from .models import GlobalConfig, HostConfig, SshCredential
from .self_check import SelfCheck
from .ssh_keys import identity_path
CRITICAL_ROOT_EXCLUDES = ("/proc/***", "/sys/***", "/dev/***", "/run/***", "/tmp/***")
def collect_global_config_checks(global_config: GlobalConfig) -> list[SelfCheck]:
checks = [
_absolute_path_check("Global backup root", global_config.backup_root),
_absolute_path_check("Runtime state root", settings.POBSYNC_HOME),
_runtime_backup_root_check(global_config),
_rsync_binary_check(global_config.rsync_binary),
_rsync_recursion_check(
"Global rsync recursion",
[*list(global_config.rsync_args or []), *list(global_config.rsync_extra_args or [])],
),
_source_root_check("Global source root", global_config.default_source_root),
_root_excludes_check(global_config.default_source_root, list(global_config.excludes_default or [])),
_retention_check(
"Global retention",
global_config.retention_daily,
global_config.retention_weekly,
global_config.retention_monthly,
global_config.retention_yearly,
),
_ssh_port_check("Global SSH port", global_config.ssh_port),
_credential_check("Global SSH credential", global_config.default_ssh_credential),
]
return checks
def collect_effective_host_config_checks(host: HostConfig, global_config: GlobalConfig) -> list[SelfCheck]:
source_root = host.source_root or global_config.default_source_root
ssh_user = host.ssh_user or global_config.ssh_user
ssh_port = host.ssh_port or global_config.ssh_port
credential = host.ssh_credential or global_config.default_ssh_credential
if host.excludes_replace is not None:
excludes = list(host.excludes_replace)
else:
excludes = [*list(global_config.excludes_default or []), *list(host.excludes_add or [])]
rsync_args = [
*list(global_config.rsync_args or []),
*list(global_config.rsync_extra_args or []),
*list(host.rsync_extra_args or []),
]
checks = [
_source_root_check("Host effective source root", source_root),
_ssh_user_check(ssh_user),
_ssh_port_check("Host effective SSH port", ssh_port),
_credential_check("Host effective SSH credential", credential),
_rsync_recursion_check("Host effective rsync recursion", rsync_args),
_root_excludes_check(source_root, excludes, host=host),
_includes_check(host),
_retention_check(
"Host retention",
host.retention_daily,
host.retention_weekly,
host.retention_monthly,
host.retention_yearly,
),
]
return checks
def has_recursive_rsync_arg(args: list[str]) -> bool:
for arg in args:
if arg in {"--archive", "--recursive"}:
return True
if arg.startswith("-") and not arg.startswith("--") and any(flag in arg for flag in ("a", "r")):
return True
return False
def _absolute_path_check(name: str, value: str) -> SelfCheck:
path = Path(value)
if not value:
return SelfCheck(name, "failed", "Path is empty.")
if not path.is_absolute():
return SelfCheck(name, "failed", f"{value} is not absolute.")
return SelfCheck(name, "ok", value)
def _runtime_backup_root_check(global_config: GlobalConfig) -> SelfCheck:
if global_config.backup_root == settings.POBSYNC_BACKUP_ROOT:
return SelfCheck("Runtime backup root", "ok", global_config.backup_root)
return SelfCheck(
"Runtime backup root",
"warning",
"Database backup root differs from the runtime backup root.",
f"database={global_config.backup_root} runtime={settings.POBSYNC_BACKUP_ROOT}",
)
def _rsync_binary_check(binary: str) -> SelfCheck:
if not binary:
return SelfCheck("Global rsync binary", "failed", "Rsync binary is empty.")
if Path(binary).is_absolute():
exists = Path(binary).exists()
message = binary if exists else f"{binary} does not exist."
return SelfCheck("Global rsync binary", "ok" if exists else "failed", message)
path = shutil.which(binary)
return SelfCheck("Global rsync binary", "ok" if path else "failed", path or f"{binary} was not found in PATH.")
def _rsync_recursion_check(name: str, args: list[str]) -> SelfCheck:
if has_recursive_rsync_arg(args):
return SelfCheck(name, "ok", "Rsync args include archive or recursive transfer.", " ".join(args))
return SelfCheck(
name,
"failed",
"Rsync args do not include archive or recursive transfer.",
"Add --archive or --recursive before running a real backup.",
)
def _source_root_check(name: str, source_root: str) -> SelfCheck:
if not source_root:
return SelfCheck(name, "failed", "Source root is empty.")
if not source_root.startswith("/"):
return SelfCheck(name, "failed", f"{source_root} is not absolute.")
return SelfCheck(name, "ok", source_root)
def _root_excludes_check(source_root: str, excludes: list[str], host: HostConfig | None = None) -> SelfCheck:
if source_root != "/":
return SelfCheck("Effective root excludes", "ok", "Source root is not /, critical OS excludes are less important.")
missing = [pattern for pattern in CRITICAL_ROOT_EXCLUDES if pattern not in excludes]
if missing:
detail = ", ".join(missing)
if host and host.excludes_replace is not None:
detail = f"excludes_replace is active; missing {detail}"
return SelfCheck(
"Effective root excludes",
"warning",
"Source root is / but some critical default excludes are missing.",
detail,
)
return SelfCheck("Effective root excludes", "ok", "Critical root excludes are present.")
def _includes_check(host: HostConfig) -> SelfCheck:
includes = list(host.includes or [])
if not includes:
return SelfCheck("Host includes", "ok", "No host include rules are configured.")
return SelfCheck(
"Host includes",
"warning",
"Includes are passed to rsync as raw --include rules.",
"Verify matching exclude rules if you intend to limit the backup scope.",
)
def _retention_check(name: str, daily: int, weekly: int, monthly: int, yearly: int) -> SelfCheck:
if any(value > 0 for value in (daily, weekly, monthly, yearly)):
return SelfCheck(name, "ok", f"d{daily} w{weekly} m{monthly} y{yearly}")
return SelfCheck(name, "warning", "All retention windows are zero.")
def _ssh_user_check(user: str) -> SelfCheck:
if user.strip():
return SelfCheck("Host effective SSH user", "ok", user.strip())
return SelfCheck("Host effective SSH user", "failed", "SSH user is empty.")
def _ssh_port_check(name: str, port: int | None) -> SelfCheck:
if port is None:
return SelfCheck(name, "failed", "SSH port is empty.")
if 1 <= int(port) <= 65535:
return SelfCheck(name, "ok", str(port))
return SelfCheck(name, "failed", f"{port} is outside the valid TCP port range.")
def _credential_check(name: str, credential: SshCredential | None) -> SelfCheck:
if credential is None:
return SelfCheck(name, "warning", "No SSH credential selected.")
if credential.key_path:
key_path = identity_path(credential)
if not key_path.exists():
return SelfCheck(name, "failed", f"{key_path} does not exist.")
if not os.access(key_path, os.R_OK):
return SelfCheck(name, "failed", f"{key_path} is not readable by this process.")
return SelfCheck(name, "ok", str(credential), str(key_path))
if credential.private_key:
return SelfCheck(
name,
"warning",
f"{credential} stores private key material in the database.",
"Generated filesystem keys are recommended for native installs.",
)
return SelfCheck(name, "failed", f"{credential} has no private key material or key path.")

View File

@@ -0,0 +1,101 @@
from __future__ import annotations
from typing import Any
from django.core.exceptions import ObjectDoesNotExist
from pobsync.config.schemas import GLOBAL_SCHEMA, HOST_SCHEMA
from pobsync.validate import validate_dict
from .models import GlobalConfig, HostConfig
class ConfigRepositoryError(RuntimeError):
pass
def _global_runtime_data(global_config: GlobalConfig) -> dict[str, Any]:
data = {
"backup_root": global_config.backup_root,
"ssh": {
"user": global_config.ssh_user,
"port": global_config.ssh_port,
"options": list(global_config.ssh_options or []),
},
"rsync": {
"binary": global_config.rsync_binary,
"args": list(global_config.rsync_args or []),
"timeout_seconds": global_config.rsync_timeout_seconds,
"bwlimit_kbps": global_config.rsync_bwlimit_kbps,
"extra_args": list(global_config.rsync_extra_args or []),
},
"defaults": {
"source_root": global_config.default_source_root,
"destination_subdir": global_config.default_destination_subdir,
},
"excludes_default": list(global_config.excludes_default or []),
"retention_defaults": {
"daily": global_config.retention_daily,
"weekly": global_config.retention_weekly,
"monthly": global_config.retention_monthly,
"yearly": global_config.retention_yearly,
},
}
return validate_dict(data, GLOBAL_SCHEMA, path="global")
def _host_runtime_data(host_config: HostConfig) -> dict[str, Any]:
data: dict[str, Any] = {
"host": host_config.host,
"address": host_config.address,
"includes": list(host_config.includes or []),
"retention": {
"daily": host_config.retention_daily,
"weekly": host_config.retention_weekly,
"monthly": host_config.retention_monthly,
"yearly": host_config.retention_yearly,
},
}
if host_config.ssh_user or host_config.ssh_port:
data["ssh"] = {}
if host_config.ssh_user:
data["ssh"]["user"] = host_config.ssh_user
if host_config.ssh_port is not None:
data["ssh"]["port"] = host_config.ssh_port
if host_config.source_root:
data["source_root"] = host_config.source_root
if host_config.excludes_replace is not None:
data["excludes_replace"] = list(host_config.excludes_replace or [])
else:
data["excludes_add"] = list(host_config.excludes_add or [])
if host_config.rsync_extra_args or host_config.rsync_bwlimit_kbps is not None:
data["rsync"] = {}
if host_config.rsync_extra_args:
data["rsync"]["extra_args"] = list(host_config.rsync_extra_args or [])
if host_config.rsync_bwlimit_kbps is not None:
data["rsync"]["bwlimit_kbps"] = host_config.rsync_bwlimit_kbps
return validate_dict(data, HOST_SCHEMA, path="host")
def global_config_object_data(global_config: GlobalConfig) -> dict[str, Any]:
return _global_runtime_data(global_config)
def host_config_object_data(host_config: HostConfig) -> dict[str, Any]:
return _host_runtime_data(host_config)
def global_config_data(name: str = "default") -> dict[str, Any]:
try:
global_config = GlobalConfig.objects.get(name=name)
except ObjectDoesNotExist as exc:
raise ConfigRepositoryError(f"Missing global config {name!r}") from exc
return _global_runtime_data(global_config)
def host_config_data(host: str) -> dict[str, Any]:
try:
host_config = HostConfig.objects.get(host=host, enabled=True)
except ObjectDoesNotExist as exc:
raise ConfigRepositoryError(f"Missing enabled host {host!r}") from exc
return _host_runtime_data(host_config)

View File

@@ -0,0 +1,93 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from django.conf import settings
from pobsync.config.merge import build_effective_config
from pobsync.paths import PobsyncPaths
from .config_repository import global_config_data, host_config_data
from .models import GlobalConfig, HostConfig, SshCredential
from .ssh_keys import identity_path
class DjangoConfigSource:
def effective_config_for_host(self, host: str) -> dict[str, Any]:
config = build_effective_config(global_config_data(), host_config_data(host))
credential = _credential_for_host(host)
if credential is not None:
_attach_credential_options(config, credential)
return config
def _credential_for_host(host: str) -> SshCredential | None:
host_config = HostConfig.objects.select_related("ssh_credential").get(host=host, enabled=True)
if host_config.ssh_credential_id:
return host_config.ssh_credential
global_config = GlobalConfig.objects.select_related("default_ssh_credential").get(name="default")
return global_config.default_ssh_credential
def _attach_credential_options(config: dict[str, Any], credential: SshCredential) -> None:
ssh = config.setdefault("ssh", {})
options = list(ssh.get("options") or [])
paths = _materialize_credential(credential)
if not _has_ssh_option(options, "IdentityFile"):
options.append(f"-oIdentityFile={paths['identity_file']}")
if paths.get("known_hosts") and not _has_ssh_option(options, "UserKnownHostsFile"):
options.append(f"-oUserKnownHostsFile={paths['known_hosts']}")
if paths.get("accept_new_known_hosts"):
if not _has_ssh_option(options, "UserKnownHostsFile"):
options.append(f"-oUserKnownHostsFile={paths['accept_new_known_hosts']}")
if not _has_ssh_option(options, "StrictHostKeyChecking"):
options.append("-oStrictHostKeyChecking=accept-new")
ssh["options"] = options
config["ssh_credential"] = {
"id": credential.pk,
"name": credential.name,
"identity_file": paths["identity_file"],
"generated": credential.generated,
"storage": "filesystem" if credential.key_path else "database",
}
def _materialize_credential(credential: SshCredential) -> dict[str, str]:
paths = PobsyncPaths(home=Path(settings.POBSYNC_HOME))
credential_dir = paths.state_dir / "ssh-credentials" / str(credential.pk)
credential_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(credential_dir, 0o700)
identity_file = identity_path(credential)
if credential.key_path:
os.chmod(identity_file, 0o600)
else:
identity_file.write_text(_with_trailing_newline(credential.private_key), encoding="utf-8")
os.chmod(identity_file, 0o600)
result = {"identity_file": str(identity_file)}
if credential.known_hosts.strip():
known_hosts = credential_dir / "known_hosts"
known_hosts.write_text(_with_trailing_newline(credential.known_hosts), encoding="utf-8")
os.chmod(known_hosts, 0o600)
result["known_hosts"] = str(known_hosts)
else:
known_hosts = paths.state_dir / "known_hosts"
known_hosts.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
known_hosts.touch(mode=0o600, exist_ok=True)
os.chmod(known_hosts, 0o600)
result["accept_new_known_hosts"] = str(known_hosts)
return result
def _has_ssh_option(options: list[str], name: str) -> bool:
prefix = f"-o{name}="
spaced = f"-o{name} "
return any(option == name or option.startswith(prefix) or option.startswith(spaced) for option in options)
def _with_trailing_newline(value: str) -> str:
return value if value.endswith("\n") else f"{value}\n"

View File

@@ -0,0 +1,9 @@
from __future__ import annotations
from django.http import HttpRequest
from .access import access_context
def pobsync_access(request: HttpRequest) -> dict[str, object]:
return access_context(request)

View File

@@ -0,0 +1,468 @@
from __future__ import annotations
import os
import textwrap
import subprocess
from pathlib import Path
from tempfile import TemporaryDirectory
from django import forms
from django.conf import settings
from .models import BackupRun, GlobalConfig, HostConfig, NotificationTarget, ScheduleConfig, SshCredential
from .scheduler import parse_cron_expr
class NewlineListField(forms.CharField):
widget = forms.Textarea
def __init__(self, *args, **kwargs) -> None:
kwargs.setdefault("required", False)
super().__init__(*args, **kwargs)
def prepare_value(self, value):
if isinstance(value, list):
return "\n".join(str(item) for item in value)
return value
def to_python(self, value) -> list[str]:
if not value:
return []
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
return [line.strip() for line in str(value).splitlines() if line.strip()]
class NullableNewlineListField(NewlineListField):
def to_python(self, value) -> list[str] | None:
parsed = super().to_python(value)
return parsed or None
class HostConfigForm(forms.ModelForm):
includes = NewlineListField(help_text="One include path per line. Leave empty to include defaults.")
excludes_add = NewlineListField(help_text="One additional exclude pattern per line.")
excludes_replace = NullableNewlineListField(
help_text="Optional. When set, replaces global excludes; one pattern per line."
)
rsync_extra_args = NewlineListField(help_text="One extra rsync argument per line.")
class Meta:
model = HostConfig
fields = (
"address",
"enabled",
"ssh_credential",
"ssh_user",
"ssh_port",
"source_root",
"includes",
"excludes_add",
"excludes_replace",
"rsync_extra_args",
"rsync_bwlimit_kbps",
"retention_daily",
"retention_weekly",
"retention_monthly",
"retention_yearly",
)
help_texts = {
"ssh_credential": "Optional. Overrides the global SSH credential for this host.",
"ssh_user": "Leave empty to use the global SSH user.",
"ssh_port": "Leave empty to use the global SSH port.",
"source_root": "Leave empty to use the global default source root.",
"rsync_bwlimit_kbps": "Leave empty to inherit the global limit. Use 0 for unlimited on this host.",
}
class CreateHostConfigForm(HostConfigForm):
class Meta(HostConfigForm.Meta):
fields = ("host", *HostConfigForm.Meta.fields)
help_texts = {
**HostConfigForm.Meta.help_texts,
"host": "Stable internal host name used for backup paths.",
}
class GlobalConfigForm(forms.ModelForm):
ssh_options = NewlineListField(help_text="One SSH option per line.")
rsync_args = NewlineListField(help_text="One default rsync argument per line.")
rsync_extra_args = NewlineListField(help_text="One extra rsync argument per line.")
excludes_default = NewlineListField(help_text="One default exclude pattern per line.")
class Meta:
model = GlobalConfig
fields = (
"name",
"default_ssh_credential",
"ssh_user",
"ssh_port",
"ssh_options",
"rsync_binary",
"rsync_args",
"rsync_extra_args",
"rsync_timeout_seconds",
"rsync_bwlimit_kbps",
"default_source_root",
"default_destination_subdir",
"excludes_default",
"retention_daily",
"retention_weekly",
"retention_monthly",
"retention_yearly",
)
help_texts = {
"name": "Usually 'default'. The backup engine currently reads the default config.",
"default_ssh_credential": "Optional. Used by hosts without their own SSH credential.",
"rsync_bwlimit_kbps": "Rsync bandwidth limit in KB/s. Use 0 for unlimited.",
"default_source_root": "Used by hosts without a custom source root.",
"default_destination_subdir": "Optional subdirectory below each snapshot.",
}
def save(self, commit: bool = True):
instance = super().save(commit=False)
instance.backup_root = settings.POBSYNC_BACKUP_ROOT
if commit:
instance.save()
self.save_m2m()
return instance
class ManualBackupForm(forms.Form):
dry_run = forms.BooleanField(
label="Dry run",
required=False,
initial=True,
help_text="Queue rsync in dry-run mode without writing a snapshot.",
)
verbose_output = forms.BooleanField(
label="Verbose rsync output",
required=False,
help_text="Write itemized rsync changes, file-list progress, and stats to the run log. Dry-runs always use this.",
)
prune = forms.BooleanField(
label="Apply retention after success",
required=False,
help_text="Apply retention after a successful non-dry-run backup.",
)
prune_max_delete = forms.IntegerField(label="Retention max delete", min_value=0, initial=10)
prune_protect_bases = forms.BooleanField(
label="Protect base snapshots",
required=False,
help_text="Keep snapshots that are used as bases by other snapshots.",
)
class NotificationTargetForm(forms.ModelForm):
TERMINAL_STATUS_CHOICES = (
(BackupRun.Status.SUCCESS, BackupRun.Status.SUCCESS.label),
(BackupRun.Status.WARNING, BackupRun.Status.WARNING.label),
(BackupRun.Status.FAILED, BackupRun.Status.FAILED.label),
(BackupRun.Status.CANCELLED, BackupRun.Status.CANCELLED.label),
)
statuses = forms.MultipleChoiceField(
choices=TERMINAL_STATUS_CHOICES,
widget=forms.CheckboxSelectMultiple,
initial=[choice[0] for choice in TERMINAL_STATUS_CHOICES],
help_text="Send notifications for these terminal run statuses.",
)
email_to = forms.CharField(
widget=forms.Textarea,
required=False,
help_text="One recipient per line, or comma-separated.",
)
webhook_headers = forms.JSONField(
required=False,
widget=forms.Textarea(attrs={"rows": 4}),
help_text='Optional JSON object with extra headers, for example {"Authorization": "Bearer ..."}.',
)
class Meta:
model = NotificationTarget
fields = (
"name",
"enabled",
"channel",
"statuses",
"email_to",
"webhook_url",
"webhook_headers",
"notes",
)
widgets = {
"notes": forms.Textarea,
}
def clean(self):
cleaned_data = super().clean()
channel = cleaned_data.get("channel")
if channel == NotificationTarget.Channel.EMAIL and not cleaned_data.get("email_to", "").strip():
self.add_error("email_to", "Email targets need at least one recipient.")
if channel == NotificationTarget.Channel.WEBHOOK and not cleaned_data.get("webhook_url"):
self.add_error("webhook_url", "Webhook targets need a URL.")
return cleaned_data
def clean_email_to(self) -> str:
value = self.cleaned_data.get("email_to", "")
recipients = [line.strip() for line in value.replace(",", "\n").splitlines() if line.strip()]
return "\n".join(recipients)
class SshCredentialForm(forms.ModelForm):
private_key_file = forms.FileField(
required=False,
help_text="Optional. Upload the private key file directly to avoid copy/paste formatting problems.",
)
private_key = forms.CharField(
widget=forms.Textarea(attrs={"spellcheck": "false", "autocomplete": "off"}),
required=False,
help_text=(
"Paste the complete unencrypted OpenSSH private key, including BEGIN/END lines. "
"Leave empty when uploading a private key file."
),
)
public_key = forms.CharField(
widget=forms.Textarea(attrs={"spellcheck": "false", "autocomplete": "off"}),
required=False,
help_text="Optional. If set, pobsync verifies it matches the private key.",
)
known_hosts = forms.CharField(
widget=forms.Textarea(attrs={"spellcheck": "false", "autocomplete": "off"}),
required=False,
help_text="Optional known_hosts entries. When set, StrictHostKeyChecking can stay enabled.",
)
notes = forms.CharField(widget=forms.Textarea, required=False)
class Meta:
model = SshCredential
fields = ("name", "private_key", "public_key", "known_hosts", "notes")
def clean_private_key(self) -> str:
uploaded_file = self.files.get("private_key_file")
if uploaded_file:
try:
raw_private_key = uploaded_file.read().decode("utf-8")
except UnicodeDecodeError as exc:
raise forms.ValidationError("SSH private key files must be UTF-8 text files.") from exc
else:
raw_private_key = self.cleaned_data.get("private_key", "")
if not raw_private_key.strip():
if self.instance and self.instance.pk and self.instance.key_path:
return self.instance.private_key
raise forms.ValidationError("Paste a private key, upload a private key file, or generate a key in pobsync.")
private_key = normalize_private_key(raw_private_key)
public_key = validate_ssh_private_key(private_key)
self.derived_public_key = public_key
return f"{private_key}\n"
def clean(self):
cleaned_data = super().clean()
provided_public_key = normalize_public_key(cleaned_data.get("public_key", ""))
if provided_public_key:
cleaned_data["public_key"] = provided_public_key
elif self.instance and self.instance.pk and self.instance.key_path:
cleaned_data["public_key"] = self.instance.public_key
if cleaned_data.get("private_key") and provided_public_key and hasattr(self, "derived_public_key"):
if public_key_identity(provided_public_key) != public_key_identity(self.derived_public_key):
self.add_error(
"public_key",
forms.ValidationError("Public key does not match the supplied private key."),
)
elif cleaned_data.get("private_key") and not provided_public_key and hasattr(self, "derived_public_key"):
cleaned_data["public_key"] = self.derived_public_key
return cleaned_data
class SshCredentialGenerateForm(forms.Form):
name = forms.CharField(max_length=128)
key_type = forms.ChoiceField(
choices=(("ed25519", "ed25519"), ("rsa", "rsa")),
initial="ed25519",
help_text="ed25519 is recommended unless you need RSA for an older target.",
)
set_global_default = forms.BooleanField(
required=False,
initial=True,
help_text="Use this key as the global default when the default global config exists.",
)
known_hosts = forms.CharField(
widget=forms.Textarea(attrs={"spellcheck": "false", "autocomplete": "off"}),
required=False,
help_text="Optional known_hosts entries. This can also be filled later.",
)
notes = forms.CharField(widget=forms.Textarea, required=False)
def clean_name(self) -> str:
name = self.cleaned_data["name"].strip()
if SshCredential.objects.filter(name=name).exists():
raise forms.ValidationError("An SSH credential with this name already exists.")
return name
class RetentionApplyForm(forms.Form):
kind = forms.ChoiceField(choices=(("scheduled", "Scheduled"), ("manual", "Manual"), ("all", "All")))
protect_bases = forms.BooleanField(required=False)
max_delete = forms.IntegerField(min_value=0, initial=10)
confirm_delete_count = forms.IntegerField(min_value=0)
confirm_host = forms.CharField()
def __init__(self, *args, host_name: str, expected_delete_count: int | None = None, **kwargs) -> None:
self.host_name = host_name
self.expected_delete_count = expected_delete_count
super().__init__(*args, **kwargs)
self.fields["confirm_host"].help_text = f"Type {host_name} to confirm deletion."
if expected_delete_count is not None:
self.fields["confirm_delete_count"].help_text = (
f"Type {expected_delete_count} to confirm the current number of planned deletions."
)
def clean_confirm_host(self) -> str:
value = self.cleaned_data["confirm_host"].strip()
if value != self.host_name:
raise forms.ValidationError(f"Type {self.host_name} to confirm.")
return value
def clean_confirm_delete_count(self) -> int:
value = self.cleaned_data["confirm_delete_count"]
if self.expected_delete_count is not None and value != self.expected_delete_count:
raise forms.ValidationError(f"Type {self.expected_delete_count} to confirm the delete count.")
return value
class IncompleteCleanupForm(forms.Form):
max_delete = forms.IntegerField(min_value=0, initial=0)
confirm_delete_count = forms.IntegerField(min_value=0)
confirm_host = forms.CharField()
def __init__(self, *args, host_name: str, expected_delete_count: int, **kwargs) -> None:
self.host_name = host_name
self.expected_delete_count = expected_delete_count
super().__init__(*args, **kwargs)
self.fields["confirm_host"].help_text = f"Type {host_name} to confirm incomplete snapshot cleanup."
self.fields["confirm_delete_count"].help_text = (
f"Type {expected_delete_count} to confirm the current number of incomplete snapshots."
)
self.fields["max_delete"].help_text = (
f"Must be at least {expected_delete_count} for the incomplete snapshots shown here."
)
def clean_confirm_host(self) -> str:
value = self.cleaned_data["confirm_host"].strip()
if value != self.host_name:
raise forms.ValidationError(f"Type {self.host_name} to confirm.")
return value
def clean_confirm_delete_count(self) -> int:
value = self.cleaned_data["confirm_delete_count"]
if value != self.expected_delete_count:
raise forms.ValidationError(f"Type {self.expected_delete_count} to confirm the incomplete count.")
return value
class ScheduleConfigForm(forms.ModelForm):
cron_expr = forms.CharField(
label="Schedule expression",
help_text=(
'Five-field cron-style expression, for example "15 2 * * *". '
"This is evaluated by the pobsync scheduler service, not host cron."
),
)
prune_max_delete = forms.IntegerField(min_value=0)
class Meta:
model = ScheduleConfig
fields = (
"cron_expr",
"enabled",
"prune",
"prune_max_delete",
"prune_protect_bases",
)
def clean_cron_expr(self) -> str:
cron_expr = self.cleaned_data["cron_expr"].strip()
try:
parse_cron_expr(cron_expr)
except ValueError as exc:
raise forms.ValidationError(str(exc)) from exc
return cron_expr
def normalize_private_key(private_key: str) -> str:
normalized = private_key.replace("\r\n", "\n").replace("\r", "\n").strip().lstrip("\ufeff")
begin_marker = "-----BEGIN OPENSSH PRIVATE KEY-----"
end_marker = "-----END OPENSSH PRIVATE KEY-----"
if begin_marker in normalized and end_marker in normalized:
before_body, after_begin = normalized.split(begin_marker, 1)
body, after_end = after_begin.split(end_marker, 1)
if before_body.strip() or after_end.strip():
return normalized
compact_body = "".join(body.split())
wrapped_body = "\n".join(textwrap.wrap(compact_body, width=70))
return f"{begin_marker}\n{wrapped_body}\n{end_marker}"
return normalized
def normalize_public_key(public_key: str) -> str:
return " ".join(public_key.strip().split())
def public_key_identity(public_key: str) -> str:
parts = normalize_public_key(public_key).split()
if len(parts) >= 2:
return " ".join(parts[:2])
return normalize_public_key(public_key)
def validate_ssh_private_key(private_key: str) -> str:
if "BEGIN OPENSSH PRIVATE KEY" not in private_key:
stripped = private_key.strip()
if stripped.startswith(("ssh-ed25519 ", "ssh-rsa ", "ecdsa-sha2-", "sk-")):
raise forms.ValidationError("This looks like a public key. Paste the private key in this field.")
if "BEGIN RSA PRIVATE KEY" in stripped or "BEGIN EC PRIVATE KEY" in stripped:
raise forms.ValidationError(
"PEM private keys are not supported here yet. Convert it to an unencrypted OpenSSH key first."
)
raise forms.ValidationError("Invalid SSH private key: missing OpenSSH private key header.")
with TemporaryDirectory() as tmp:
key_path = Path(tmp) / "identity"
key_path.write_text(f"{private_key}\n", encoding="utf-8")
os.chmod(key_path, 0o600)
try:
result = subprocess.run(
["ssh-keygen", "-y", "-f", str(key_path)],
check=False,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=5,
)
except FileNotFoundError as exc:
raise forms.ValidationError("ssh-keygen is not available in this container.") from exc
except subprocess.TimeoutExpired as exc:
raise forms.ValidationError("Could not validate SSH private key before timeout.") from exc
if result.returncode != 0:
message = result.stderr.strip() or "OpenSSH could not read this private key."
lower_message = message.lower()
if "passphrase" in lower_message:
message = "Encrypted SSH private keys are not supported for unattended backups."
elif "libcrypto" in lower_message:
message = (
"OpenSSH could not parse this key. It is usually incomplete, corrupted while copying, "
"or not an unencrypted OpenSSH private key."
)
raise forms.ValidationError(f"Invalid SSH private key: {message}")
public_key = result.stdout.strip()
if not public_key:
raise forms.ValidationError("Invalid SSH private key: no public key could be derived.")
return public_key

View File

@@ -0,0 +1,103 @@
from __future__ import annotations
import os
from pathlib import Path
from pobsync.snapshot_meta import resolve_host_root
from .config_checks import collect_effective_host_config_checks
from .models import GlobalConfig, HostConfig
from .self_check import SelfCheck
from .ssh_keys import identity_path
HOST_BACKUP_SUBDIRS = ("scheduled", "manual", ".incomplete")
def ensure_host_directories(host: HostConfig, global_config: GlobalConfig | None = None) -> Path:
global_config = global_config or GlobalConfig.objects.get(name="default")
host_root = resolve_host_root(global_config.backup_root, host.host)
for subdir in HOST_BACKUP_SUBDIRS:
(host_root / subdir).mkdir(parents=True, exist_ok=True)
return host_root
def collect_host_checks(host: HostConfig, global_config: GlobalConfig | None = None) -> list[SelfCheck]:
checks: list[SelfCheck] = []
try:
global_config = global_config or GlobalConfig.objects.get(name="default")
except GlobalConfig.DoesNotExist:
return [SelfCheck("Host global config", "failed", "Default global config does not exist.")]
checks.append(
SelfCheck(
"Host enabled",
"ok" if host.enabled else "warning",
"Host is enabled." if host.enabled else "Host is disabled.",
)
)
checks.append(
SelfCheck(
"Host address",
"ok" if host.address.strip() else "failed",
host.address.strip() or "Host address is empty.",
)
)
credential = host.ssh_credential or global_config.default_ssh_credential
if credential is None:
checks.append(SelfCheck("Host SSH credential", "warning", "No host or global SSH credential selected."))
else:
checks.append(SelfCheck("Host SSH credential", "ok", str(credential)))
if credential.key_path:
key_path = identity_path(credential)
checks.append(
_host_path_check("Host SSH key file", key_path, must_exist=True, must_be_writable=False, must_be_readable=True)
)
elif credential.private_key:
checks.append(
SelfCheck(
"Host SSH key storage",
"warning",
"Selected credential stores private key material in the database.",
"Generated filesystem keys are recommended for native systemd installs.",
)
)
if credential.known_hosts.strip():
checks.append(SelfCheck("Host known_hosts", "ok", "Selected credential has known_hosts entries."))
else:
checks.append(
SelfCheck(
"Host known_hosts",
"warning",
"Selected credential has no pinned known_hosts entries.",
"pobsync will use service-level StrictHostKeyChecking=accept-new on first connect.",
)
)
host_root = resolve_host_root(global_config.backup_root, host.host)
checks.append(_host_path_check("Host backup root", host_root, must_exist=True, must_be_writable=True))
for subdir in HOST_BACKUP_SUBDIRS:
checks.append(_host_path_check(f"Host directory: {subdir}", host_root / subdir, must_exist=True, must_be_writable=True))
checks.extend(collect_effective_host_config_checks(host, global_config))
return checks
def _host_path_check(
name: str,
path: Path,
*,
must_exist: bool,
must_be_writable: bool,
must_be_readable: bool = False,
) -> SelfCheck:
if must_exist and not path.exists():
return SelfCheck(name, "failed", f"{path} does not exist.")
target = path if path.exists() else path.parent
if not target.exists():
return SelfCheck(name, "failed", f"{target} does not exist.")
if must_be_writable and not os.access(target, os.W_OK):
return SelfCheck(name, "failed", f"{target} is not writable by this process.")
if must_be_readable and not os.access(target, os.R_OK):
return SelfCheck(name, "failed", f"{target} is not readable by this process.")
return SelfCheck(name, "ok", str(path))

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
from django.core.management.base import BaseCommand, CommandError
from pobsync_backend.self_check import collect_self_checks, summarize_self_checks
class Command(BaseCommand):
help = "Run pobsync runtime self checks for native installs and updates."
def add_arguments(self, parser):
parser.add_argument(
"--fail-on-warning",
action="store_true",
help="Exit with an error when warnings are present.",
)
def handle(self, *args, **options):
checks = collect_self_checks()
summary = summarize_self_checks(checks)
for check in checks:
line = f"[{check.status.upper()}] {check.name}: {check.message}"
if check.detail:
line = f"{line} ({check.detail})"
if check.status == "failed":
self.stderr.write(line)
elif check.status == "warning":
self.stderr.write(line)
else:
self.stdout.write(line)
self.stdout.write(
"Summary: "
f"{summary['ok']} ok, "
f"{summary['warning']} warning(s), "
f"{summary['failed']} failed, "
f"{summary['skipped']} skipped"
)
if summary["failed"]:
raise CommandError("pobsync install self check failed.")
if options["fail_on_warning"] and summary["warning"]:
raise CommandError("pobsync install self check reported warnings.")

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
from typing import Any
from django.core.management.base import BaseCommand, CommandError
from pobsync.config.retention import parse_retention
from pobsync.config.defaults import DEFAULT_EXCLUDES, DEFAULT_RSYNC_ARGS
from pobsync.util import is_absolute_non_root
from pobsync_backend.models import GlobalConfig
class Command(BaseCommand):
help = "Create or update the default global backup configuration."
def add_arguments(self, parser) -> None:
parser.add_argument("--name", default="default")
parser.add_argument("--backup-root", required=True)
parser.add_argument("--ssh-user", default="root")
parser.add_argument("--ssh-port", type=int, default=22)
parser.add_argument("--source-root", default="/")
parser.add_argument("--retention", default="daily=14,weekly=8,monthly=12,yearly=0")
parser.add_argument("--force", action="store_true", help="Update existing config")
def handle(self, *args: Any, **options: Any) -> None:
backup_root = options["backup_root"]
if not is_absolute_non_root(backup_root):
raise CommandError("--backup-root must be an absolute path and must not be '/'")
retention = parse_retention(options["retention"])
defaults = {
"backup_root": backup_root,
"ssh_user": options["ssh_user"],
"ssh_port": options["ssh_port"],
"ssh_options": ["-oBatchMode=yes", "-oStrictHostKeyChecking=accept-new"],
"rsync_binary": "rsync",
"rsync_args": DEFAULT_RSYNC_ARGS,
"rsync_extra_args": [],
"rsync_timeout_seconds": 0,
"rsync_bwlimit_kbps": 0,
"default_source_root": options["source_root"],
"default_destination_subdir": "",
"excludes_default": DEFAULT_EXCLUDES,
"retention_daily": retention["daily"],
"retention_weekly": retention["weekly"],
"retention_monthly": retention["monthly"],
"retention_yearly": retention["yearly"],
}
if GlobalConfig.objects.filter(name=options["name"]).exists() and not options["force"]:
raise CommandError(f"Global config {options['name']!r} already exists; use --force to update")
_obj, created = GlobalConfig.objects.update_or_create(name=options["name"], defaults=defaults)
action = "Created" if created else "Updated"
self.stdout.write(self.style.SUCCESS(f"{action} global config {options['name']!r}."))

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
from typing import Any
from django.core.management.base import BaseCommand, CommandError
from pobsync.config.retention import parse_retention
from pobsync.util import sanitize_host
from pobsync_backend.models import GlobalConfig, HostConfig
class Command(BaseCommand):
help = "Create or update a host backup configuration."
def add_arguments(self, parser) -> None:
parser.add_argument("host")
parser.add_argument("--address", required=True)
parser.add_argument("--ssh-user", default="")
parser.add_argument("--ssh-port", type=int, default=None)
parser.add_argument("--source-root", default="")
parser.add_argument("--include", action="append", default=[])
parser.add_argument("--exclude-add", action="append", default=[])
parser.add_argument("--exclude-replace", action="append", default=None)
parser.add_argument("--rsync-extra-arg", action="append", default=[])
parser.add_argument(
"--rsync-bwlimit-kbps",
type=int,
default=None,
help="Host rsync bandwidth limit in KB/s. Omit to inherit global; set 0 for unlimited.",
)
parser.add_argument("--retention", default=None)
parser.add_argument("--disabled", action="store_true")
parser.add_argument("--force", action="store_true", help="Update existing host")
def handle(self, *args: Any, **options: Any) -> None:
host = sanitize_host(options["host"])
if HostConfig.objects.filter(host=host).exists() and not options["force"]:
raise CommandError(f"Host {host!r} already exists; use --force to update")
retention = self._retention(options["retention"])
defaults = {
"address": options["address"],
"enabled": not options["disabled"],
"ssh_user": options["ssh_user"],
"ssh_port": options["ssh_port"],
"source_root": options["source_root"],
"includes": list(options["include"]),
"excludes_add": [] if options["exclude_replace"] is not None else list(options["exclude_add"]),
"excludes_replace": options["exclude_replace"],
"rsync_extra_args": list(options["rsync_extra_arg"]),
"rsync_bwlimit_kbps": options["rsync_bwlimit_kbps"],
"retention_daily": retention["daily"],
"retention_weekly": retention["weekly"],
"retention_monthly": retention["monthly"],
"retention_yearly": retention["yearly"],
}
_obj, created = HostConfig.objects.update_or_create(host=host, defaults=defaults)
action = "Created" if created else "Updated"
self.stdout.write(self.style.SUCCESS(f"{action} host {host!r}."))
def _retention(self, value: str | None) -> dict[str, int]:
if value:
return parse_retention(value)
global_config = GlobalConfig.objects.filter(name="default").first()
if global_config is None:
return {"daily": 14, "weekly": 8, "monthly": 12, "yearly": 0}
return {
"daily": global_config.retention_daily,
"weekly": global_config.retention_weekly,
"monthly": global_config.retention_monthly,
"yearly": global_config.retention_yearly,
}

View File

@@ -0,0 +1,59 @@
from __future__ import annotations
from typing import Any
from django.core.management.base import BaseCommand, CommandError
from pobsync_backend.models import HostConfig, ScheduleConfig
from pobsync_backend.scheduler import parse_cron_expr
class Command(BaseCommand):
help = "Create, update, disable, or remove a scheduler-managed host schedule."
def add_arguments(self, parser) -> None:
parser.add_argument("host")
parser.add_argument(
"--schedule-expression",
"--cron",
dest="schedule_expression",
help='Five-field schedule expression, e.g. "15 2 * * *"',
)
parser.add_argument("--prune", action="store_true")
parser.add_argument("--prune-max-delete", type=int, default=10)
parser.add_argument("--prune-protect-bases", action="store_true")
parser.add_argument("--disabled", action="store_true")
parser.add_argument("--delete", action="store_true")
def handle(self, *args: Any, **options: Any) -> None:
try:
host = HostConfig.objects.get(host=options["host"])
except HostConfig.DoesNotExist as exc:
raise CommandError(f"Missing host {options['host']!r}") from exc
if options["delete"]:
deleted, _details = ScheduleConfig.objects.filter(host=host).delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {deleted} schedule row(s) for {host.host!r}."))
return
schedule_expression = options["schedule_expression"]
if not schedule_expression:
raise CommandError("--schedule-expression is required unless --delete is used")
try:
parse_cron_expr(schedule_expression)
except ValueError as exc:
raise CommandError(str(exc)) from exc
schedule, created = ScheduleConfig.objects.update_or_create(
host=host,
defaults={
"cron_expr": schedule_expression,
"enabled": not options["disabled"],
"prune": bool(options["prune"]),
"prune_max_delete": int(options["prune_max_delete"]),
"prune_protect_bases": bool(options["prune_protect_bases"]),
},
)
action = "Created" if created else "Updated"
state = "enabled" if schedule.enabled else "disabled"
self.stdout.write(self.style.SUCCESS(f"{action} {state} schedule for {host.host!r}."))

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from typing import Any
from django.core.management.base import BaseCommand, CommandError
from pobsync.snapshot_meta import normalize_kind
from pobsync_backend.models import GlobalConfig, HostConfig
from pobsync_backend.snapshot_discovery import discover_snapshots
class Command(BaseCommand):
help = "Discover snapshot metadata on disk and upsert SnapshotRecord rows."
def add_arguments(self, parser) -> None:
parser.add_argument("--host", default=None)
parser.add_argument("--kind", default="all", help="scheduled|manual|incomplete|all")
def handle(self, *args: Any, **options: Any) -> None:
try:
global_config = GlobalConfig.objects.get(name="default")
except GlobalConfig.DoesNotExist as exc:
raise CommandError("Missing default global config") from exc
host = None
if options["host"]:
try:
host = HostConfig.objects.get(host=options["host"], enabled=True)
except HostConfig.DoesNotExist as exc:
raise CommandError(f"Missing enabled host {options['host']!r}") from exc
kind = normalize_kind(options["kind"])
kinds = ["scheduled", "manual", "incomplete"] if kind == "all" else [kind]
result = discover_snapshots(host=host, global_config=global_config, kinds=kinds)
self.stdout.write(
self.style.SUCCESS(
f"Scanned {result['scanned']} snapshot(s), created {result['created']}, updated {result['updated']}."
)
)

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from django.core.management.base import BaseCommand, CommandError
from pobsync_backend.models import GlobalConfig, SshCredential
from pobsync_backend.ssh_keys import SshKeyError, generate_ssh_key
class Command(BaseCommand):
help = "Ensure a filesystem-backed SSH key exists for pobsync backups."
def add_arguments(self, parser):
parser.add_argument("--name", default="default", help="Credential name to create or reuse.")
parser.add_argument("--key-type", default="ed25519", choices=("ed25519", "rsa"))
parser.add_argument(
"--set-global-default",
action="store_true",
help="Set this key as default on the default global config when it exists.",
)
def handle(self, *args, **options):
name = options["name"]
credential, created = SshCredential.objects.get_or_create(
name=name,
defaults={
"key_type": options["key_type"],
"notes": "Generated by pobsync installer.",
},
)
if not credential.key_path and not credential.private_key:
try:
generate_ssh_key(credential, key_type=options["key_type"])
except SshKeyError as exc:
raise CommandError(str(exc)) from exc
created = True
if options["set_global_default"]:
global_config = GlobalConfig.objects.filter(name="default").first()
if global_config is not None and global_config.default_ssh_credential_id is None:
global_config.default_ssh_credential = credential
global_config.save(update_fields=["default_ssh_credential", "updated_at"])
action = "created" if created else "exists"
self.stdout.write(self.style.SUCCESS(f"SSH credential {action}: {credential.name}"))
if credential.public_key:
self.stdout.write(credential.public_key)

View File

@@ -0,0 +1,69 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from pobsync.paths import PobsyncPaths
from pobsync_backend.backup_runner import execute_backup_run
from pobsync_backend.models import BackupRun, HostConfig
class Command(BaseCommand):
help = "Run a pobsync backup and record the result in Django."
def add_arguments(self, parser) -> None:
parser.add_argument("host", help="Host to back up")
parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Runtime state root")
parser.add_argument("--dry-run", action="store_true", help="Run rsync --dry-run")
parser.add_argument("--verbose-rsync", action="store_true", help="Write itemized rsync output to the run log")
parser.add_argument("--quiet-rsync", action="store_true", help="Skip default rsync progress output for real runs")
parser.add_argument("--prune", action="store_true", help="Apply retention after a successful run")
parser.add_argument("--prune-max-delete", type=int, default=10)
parser.add_argument("--prune-protect-bases", action="store_true")
parser.add_argument("--manual", action="store_true", help="Record the run as manual instead of scheduled")
def handle(self, *args: Any, **options: Any) -> None:
host_name = options["host"]
paths = PobsyncPaths(home=Path(options["prefix"]))
try:
host = HostConfig.objects.get(host=host_name, enabled=True)
except HostConfig.DoesNotExist as exc:
raise CommandError(f"Missing enabled host {host_name!r}") from exc
verbose_output = bool(options["dry_run"] or options["verbose_rsync"] or not options["quiet_rsync"])
run = BackupRun.objects.create(
host=host,
run_type=BackupRun.RunType.MANUAL if options["manual"] else BackupRun.RunType.SCHEDULED,
status=BackupRun.Status.RUNNING,
result={
"requested": {
"dry_run": bool(options["dry_run"]),
"verbose_output": verbose_output,
"prune": bool(options["prune"]),
"prune_max_delete": int(options["prune_max_delete"]),
"prune_protect_bases": bool(options["prune_protect_bases"]),
}
},
)
execute_backup_run(
run=run,
prefix=paths.home,
dry_run=bool(options["dry_run"]),
verbose_output=verbose_output,
prune=bool(options["prune"]),
prune_max_delete=int(options["prune_max_delete"]),
prune_protect_bases=bool(options["prune_protect_bases"]),
)
run.refresh_from_db()
if run.status == BackupRun.Status.SUCCESS:
self.stdout.write(self.style.SUCCESS(f"Backup completed for {host.host}."))
return
if run.status == BackupRun.Status.WARNING:
self.stdout.write(self.style.WARNING(f"Backup completed with warnings for {host.host}; run id={run.id}"))
return
raise CommandError(f"Backup failed for {host.host}; run id={run.id}")

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from pobsync.errors import ConfigError
from pobsync_backend.retention import run_sql_retention_apply, run_sql_retention_plan
class Command(BaseCommand):
help = "Plan or apply retention using the Django backup configuration."
def add_arguments(self, parser) -> None:
parser.add_argument("host")
parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Runtime state root")
parser.add_argument("--kind", default="scheduled", choices=["scheduled", "manual", "all"])
parser.add_argument("--protect-bases", action="store_true")
parser.add_argument("--apply", action="store_true")
parser.add_argument("--yes", action="store_true")
parser.add_argument("--max-delete", type=int, default=10)
def handle(self, *args: Any, **options: Any) -> None:
host = options["host"]
try:
if options["apply"]:
if not options["yes"]:
raise CommandError("--yes is required with --apply")
result = run_sql_retention_apply(
prefix=Path(options["prefix"]),
host=host,
kind=options["kind"],
protect_bases=bool(options["protect_bases"]),
yes=True,
max_delete=int(options["max_delete"]),
action="cli",
)
else:
result = run_sql_retention_plan(
host=host,
kind=options["kind"],
protect_bases=bool(options["protect_bases"]),
)
except ConfigError as exc:
raise CommandError(str(exc)) from exc
self.stdout.write(json.dumps(result, indent=2, sort_keys=False))

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
import time
from pathlib import Path
from typing import Any
from django.conf import settings
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
from pobsync_backend.models import BackupRun, ScheduleConfig
from pobsync_backend.scheduler import due_key, is_due
class Command(BaseCommand):
help = "Run due pobsync schedules from the Django database."
def add_arguments(self, parser) -> None:
parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Runtime state root")
parser.add_argument("--once", action="store_true", help="Check once and exit")
parser.add_argument("--loop", action="store_true", help="Keep checking schedules")
parser.add_argument("--interval", type=int, default=60, help="Loop interval in seconds")
parser.add_argument("--dry-run", action="store_true", help="Pass --dry-run to backup runs")
def handle(self, *args: Any, **options: Any) -> None:
if not options["once"] and not options["loop"]:
options["once"] = True
prefix = Path(options["prefix"])
while True:
count = self._run_due(prefix=prefix, dry_run=bool(options["dry_run"]))
self.stdout.write(f"Ran {count} due schedule(s).")
if options["once"]:
return
time.sleep(max(1, int(options["interval"])))
def _run_due(self, *, prefix: Path, dry_run: bool) -> int:
now = timezone.localtime(timezone.now())
current_due_key = due_key(now)
ran = 0
schedules = (
ScheduleConfig.objects.select_related("host")
.filter(enabled=True, host__enabled=True)
.order_by("host__host")
)
for schedule in schedules:
if schedule.last_due_key == current_due_key:
continue
if not is_due(schedule.cron_expr, now):
continue
schedule_started_at = timezone.now()
with transaction.atomic():
locked = ScheduleConfig.objects.select_for_update().get(pk=schedule.pk)
if locked.last_due_key == current_due_key:
continue
locked.last_due_key = current_due_key
locked.last_started_at = schedule_started_at
locked.last_status = "running"
locked.save(update_fields=["last_due_key", "last_started_at", "last_status", "updated_at"])
status = "success"
try:
call_command(
"run_pobsync_backup",
schedule.host.host,
prefix=str(prefix),
dry_run=dry_run,
prune=schedule.prune,
prune_max_delete=schedule.prune_max_delete,
prune_protect_bases=schedule.prune_protect_bases,
)
status = _latest_scheduled_run_status(host_id=schedule.host_id, started_at=schedule_started_at) or status
except Exception as exc:
status = "failed"
self.stderr.write(f"{schedule.host.host}: {type(exc).__name__}: {exc}")
finally:
ScheduleConfig.objects.filter(pk=schedule.pk).update(
last_finished_at=timezone.now(),
last_status=status,
)
ran += 1
return ran
def _latest_scheduled_run_status(*, host_id: int, started_at) -> str | None:
run = (
BackupRun.objects.filter(
host_id=host_id,
run_type=BackupRun.RunType.SCHEDULED,
created_at__gte=started_at,
)
.order_by("-created_at", "-id")
.first()
)
return run.status if run is not None else None

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
import time
from pathlib import Path
from typing import Any
from django.conf import settings
from django.core.management.base import BaseCommand
from pobsync.paths import PobsyncPaths
from pobsync_backend.backup_runner import claim_next_queued_run, execute_backup_run, reconcile_running_runs, requested_options
class Command(BaseCommand):
help = "Run queued pobsync backup jobs from the Django database."
def add_arguments(self, parser) -> None:
parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Runtime state root")
parser.add_argument("--once", action="store_true", help="Process one queued run and exit")
parser.add_argument("--loop", action="store_true", help="Keep checking for queued runs")
parser.add_argument("--interval", type=int, default=15, help="Loop interval in seconds")
parser.add_argument(
"--stale-running-seconds",
type=int,
default=24 * 60 * 60,
help="Mark running runs failed after this many seconds without a worker heartbeat; use 0 to disable",
)
def handle(self, *args: Any, **options: Any) -> None:
if not options["once"] and not options["loop"]:
options["once"] = True
paths = PobsyncPaths(home=Path(options["prefix"]))
while True:
count = self._run_once(prefix=paths.home, stale_running_seconds=int(options["stale_running_seconds"]))
self.stdout.write(f"Ran {count} queued backup run(s).")
if options["once"]:
return
time.sleep(max(1, int(options["interval"])))
def _run_once(self, *, prefix: Path, stale_running_seconds: int = 24 * 60 * 60) -> int:
reconciled = reconcile_running_runs(stale_worker_seconds=stale_running_seconds)
run = claim_next_queued_run()
if run is None:
return reconciled
options = requested_options(run)
try:
execute_backup_run(
run=run,
prefix=prefix,
dry_run=bool(options.get("dry_run", False)),
verbose_output=bool(options.get("verbose_output", False)),
prune=bool(options.get("prune", False)),
prune_max_delete=int(options.get("prune_max_delete", 10)),
prune_protect_bases=bool(options.get("prune_protect_bases", False)),
)
except Exception as exc:
self.stderr.write(f"{run.host.host}: {type(exc).__name__}: {exc}")
return reconciled + 1

View File

@@ -0,0 +1,103 @@
from __future__ import annotations
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="GlobalConfig",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(default="default", max_length=64, unique=True)),
("backup_root", models.CharField(max_length=512)),
("pobsync_home", models.CharField(default="/opt/pobsync", max_length=512)),
("data", models.JSONField(blank=True, default=dict)),
],
options={
"verbose_name": "global config",
"verbose_name_plural": "global configs",
},
),
migrations.CreateModel(
name="HostConfig",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("host", models.CharField(max_length=255, unique=True)),
("address", models.CharField(max_length=255)),
("enabled", models.BooleanField(default=True)),
("config", models.JSONField(blank=True, default=dict)),
],
options={
"ordering": ["host"],
},
),
migrations.CreateModel(
name="BackupRun",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("run_type", models.CharField(choices=[("scheduled", "Scheduled"), ("manual", "Manual")], default="scheduled", max_length=16)),
("status", models.CharField(choices=[("queued", "Queued"), ("running", "Running"), ("success", "Success"), ("failed", "Failed"), ("cancelled", "Cancelled")], default="queued", max_length=16)),
("started_at", models.DateTimeField(blank=True, null=True)),
("ended_at", models.DateTimeField(blank=True, null=True)),
("snapshot_path", models.CharField(blank=True, max_length=1024)),
("base_path", models.CharField(blank=True, max_length=1024)),
("rsync_exit_code", models.IntegerField(blank=True, null=True)),
("result", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("host", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="runs", to="pobsync_backend.hostconfig")),
],
options={
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name="ScheduleConfig",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("cron_expr", models.CharField(max_length=128)),
("user", models.CharField(default="root", max_length=64)),
("enabled", models.BooleanField(default=True)),
("prune", models.BooleanField(default=False)),
("prune_max_delete", models.PositiveIntegerField(default=10)),
("prune_protect_bases", models.BooleanField(default=False)),
("host", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="schedule", to="pobsync_backend.hostconfig")),
],
options={
"ordering": ["host__host"],
},
),
migrations.CreateModel(
name="SnapshotRecord",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("kind", models.CharField(choices=[("scheduled", "Scheduled"), ("manual", "Manual"), ("incomplete", "Incomplete")], max_length=16)),
("dirname", models.CharField(max_length=255)),
("path", models.CharField(max_length=1024)),
("status", models.CharField(blank=True, max_length=32)),
("started_at", models.DateTimeField(blank=True, null=True)),
("ended_at", models.DateTimeField(blank=True, null=True)),
("metadata", models.JSONField(blank=True, default=dict)),
("discovered_at", models.DateTimeField(auto_now_add=True)),
("host", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="snapshots", to="pobsync_backend.hostconfig")),
],
options={
"ordering": ["host__host", "-started_at", "dirname"],
},
),
migrations.AddConstraint(
model_name="snapshotrecord",
constraint=models.UniqueConstraint(fields=("host", "kind", "dirname"), name="unique_snapshot_per_host_kind"),
),
]

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pobsync_backend", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="scheduleconfig",
name="last_due_key",
field=models.CharField(blank=True, max_length=32),
),
migrations.AddField(
model_name="scheduleconfig",
name="last_finished_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="scheduleconfig",
name="last_started_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="scheduleconfig",
name="last_status",
field=models.CharField(blank=True, max_length=16),
),
]

View File

@@ -0,0 +1,191 @@
from __future__ import annotations
from django.db import migrations, models
def copy_json_config_to_fields(apps, schema_editor) -> None:
GlobalConfig = apps.get_model("pobsync_backend", "GlobalConfig")
HostConfig = apps.get_model("pobsync_backend", "HostConfig")
for global_config in GlobalConfig.objects.all():
data = global_config.data or {}
ssh = data.get("ssh") or {}
rsync = data.get("rsync") or {}
defaults = data.get("defaults") or {}
retention = data.get("retention_defaults") or {}
global_config.ssh_user = ssh.get("user") or global_config.ssh_user
global_config.ssh_port = ssh.get("port") or global_config.ssh_port
global_config.ssh_options = ssh.get("options") or []
global_config.rsync_binary = rsync.get("binary") or global_config.rsync_binary
global_config.rsync_args = rsync.get("args") or []
global_config.rsync_extra_args = rsync.get("extra_args") or []
global_config.rsync_timeout_seconds = rsync.get("timeout_seconds") or 0
global_config.rsync_bwlimit_kbps = rsync.get("bwlimit_kbps") or 0
global_config.default_source_root = defaults.get("source_root") or "/"
global_config.default_destination_subdir = defaults.get("destination_subdir") or ""
global_config.excludes_default = data.get("excludes_default") or []
global_config.retention_daily = retention.get("daily", global_config.retention_daily)
global_config.retention_weekly = retention.get("weekly", global_config.retention_weekly)
global_config.retention_monthly = retention.get("monthly", global_config.retention_monthly)
global_config.retention_yearly = retention.get("yearly", global_config.retention_yearly)
global_config.save()
for host_config in HostConfig.objects.all():
config = host_config.config or {}
ssh = config.get("ssh") or {}
rsync = config.get("rsync") or {}
retention = config.get("retention") or {}
host_config.ssh_user = ssh.get("user") or ""
host_config.ssh_port = ssh.get("port")
host_config.source_root = config.get("source_root") or ""
host_config.includes = config.get("includes") or []
host_config.excludes_add = config.get("excludes_add") or []
host_config.excludes_replace = config.get("excludes_replace")
host_config.rsync_extra_args = rsync.get("extra_args") or []
host_config.retention_daily = retention.get("daily", host_config.retention_daily)
host_config.retention_weekly = retention.get("weekly", host_config.retention_weekly)
host_config.retention_monthly = retention.get("monthly", host_config.retention_monthly)
host_config.retention_yearly = retention.get("yearly", host_config.retention_yearly)
host_config.save()
class Migration(migrations.Migration):
dependencies = [
("pobsync_backend", "0002_schedule_run_state"),
]
operations = [
migrations.AddField(
model_name="globalconfig",
name="default_destination_subdir",
field=models.CharField(blank=True, default="", max_length=512),
),
migrations.AddField(
model_name="globalconfig",
name="default_source_root",
field=models.CharField(default="/", max_length=512),
),
migrations.AddField(
model_name="globalconfig",
name="excludes_default",
field=models.JSONField(blank=True, default=list),
),
migrations.AddField(
model_name="globalconfig",
name="retention_daily",
field=models.PositiveIntegerField(default=14),
),
migrations.AddField(
model_name="globalconfig",
name="retention_monthly",
field=models.PositiveIntegerField(default=12),
),
migrations.AddField(
model_name="globalconfig",
name="retention_weekly",
field=models.PositiveIntegerField(default=8),
),
migrations.AddField(
model_name="globalconfig",
name="retention_yearly",
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name="globalconfig",
name="rsync_args",
field=models.JSONField(blank=True, default=list),
),
migrations.AddField(
model_name="globalconfig",
name="rsync_binary",
field=models.CharField(default="rsync", max_length=128),
),
migrations.AddField(
model_name="globalconfig",
name="rsync_bwlimit_kbps",
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name="globalconfig",
name="rsync_extra_args",
field=models.JSONField(blank=True, default=list),
),
migrations.AddField(
model_name="globalconfig",
name="rsync_timeout_seconds",
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name="globalconfig",
name="ssh_options",
field=models.JSONField(blank=True, default=list),
),
migrations.AddField(
model_name="globalconfig",
name="ssh_port",
field=models.PositiveIntegerField(default=22),
),
migrations.AddField(
model_name="globalconfig",
name="ssh_user",
field=models.CharField(default="root", max_length=64),
),
migrations.AddField(
model_name="hostconfig",
name="excludes_add",
field=models.JSONField(blank=True, default=list),
),
migrations.AddField(
model_name="hostconfig",
name="excludes_replace",
field=models.JSONField(blank=True, null=True),
),
migrations.AddField(
model_name="hostconfig",
name="includes",
field=models.JSONField(blank=True, default=list),
),
migrations.AddField(
model_name="hostconfig",
name="retention_daily",
field=models.PositiveIntegerField(default=14),
),
migrations.AddField(
model_name="hostconfig",
name="retention_monthly",
field=models.PositiveIntegerField(default=12),
),
migrations.AddField(
model_name="hostconfig",
name="retention_weekly",
field=models.PositiveIntegerField(default=8),
),
migrations.AddField(
model_name="hostconfig",
name="retention_yearly",
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name="hostconfig",
name="rsync_extra_args",
field=models.JSONField(blank=True, default=list),
),
migrations.AddField(
model_name="hostconfig",
name="source_root",
field=models.CharField(blank=True, max_length=512),
),
migrations.AddField(
model_name="hostconfig",
name="ssh_port",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="hostconfig",
name="ssh_user",
field=models.CharField(blank=True, max_length=64),
),
migrations.RunPython(copy_json_config_to_fields, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pobsync_backend", "0003_structured_config_fields"),
]
operations = [
migrations.AddField(
model_name="backuprun",
name="snapshot",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="backup_runs",
to="pobsync_backend.snapshotrecord",
),
),
]

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pobsync_backend", "0004_backuprun_snapshot"),
]
operations = [
migrations.AddField(
model_name="snapshotrecord",
name="base",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="derived_snapshots",
to="pobsync_backend.snapshotrecord",
),
),
migrations.AddField(
model_name="snapshotrecord",
name="base_dirname",
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name="snapshotrecord",
name="base_snapshot_id",
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name="snapshotrecord",
name="base_kind",
field=models.CharField(blank=True, max_length=16),
),
migrations.AddField(
model_name="snapshotrecord",
name="base_path",
field=models.CharField(blank=True, max_length=1024),
),
]

View File

@@ -0,0 +1,51 @@
# Generated by Django 5.2.14 on 2026-05-19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pobsync_backend", "0005_snapshotrecord_base"),
]
operations = [
migrations.CreateModel(
name="SshCredential",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=128, unique=True)),
("private_key", models.TextField()),
("public_key", models.TextField(blank=True)),
("known_hosts", models.TextField(blank=True)),
("notes", models.TextField(blank=True)),
],
options={
"ordering": ["name"],
},
),
migrations.AddField(
model_name="globalconfig",
name="default_ssh_credential",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="global_configs",
to="pobsync_backend.sshcredential",
),
),
migrations.AddField(
model_name="hostconfig",
name="ssh_credential",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="hosts",
to="pobsync_backend.sshcredential",
),
),
]

View File

@@ -0,0 +1,35 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pobsync_backend", "0006_ssh_credentials"),
]
operations = [
migrations.AlterField(
model_name="sshcredential",
name="private_key",
field=models.TextField(blank=True, default=""),
),
migrations.AddField(
model_name="sshcredential",
name="key_path",
field=models.CharField(blank=True, max_length=1024),
),
migrations.AddField(
model_name="sshcredential",
name="key_type",
field=models.CharField(default="ed25519", max_length=32),
),
migrations.AddField(
model_name="sshcredential",
name="fingerprint",
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name="sshcredential",
name="generated",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,27 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pobsync_backend", "0007_filesystem_ssh_credentials"),
]
operations = [
migrations.AlterField(
model_name="backuprun",
name="status",
field=models.CharField(
choices=[
("queued", "Queued"),
("running", "Running"),
("success", "Success"),
("warning", "Warning"),
("failed", "Failed"),
("cancelled", "Cancelled"),
],
default="queued",
max_length=16,
),
),
]

View File

@@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("pobsync_backend", "0008_alter_backuprun_status"),
]
operations = [
migrations.RemoveField(
model_name="scheduleconfig",
name="user",
),
]

View File

@@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("pobsync_backend", "0009_remove_scheduleconfig_user"),
]
operations = [
migrations.RemoveField(
model_name="globalconfig",
name="pobsync_home",
),
]

View File

@@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("pobsync_backend", "0010_remove_globalconfig_pobsync_home"),
]
operations = [
migrations.RemoveField(
model_name="globalconfig",
name="data",
),
]

View File

@@ -0,0 +1,30 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pobsync_backend", "0011_remove_globalconfig_data"),
]
operations = [
migrations.AddField(
model_name="backuprun",
name="reviewed_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="backuprun",
name="reviewed_by",
field=models.CharField(blank=True, max_length=150),
),
migrations.AddField(
model_name="snapshotrecord",
name="reviewed_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="snapshotrecord",
name="reviewed_by",
field=models.CharField(blank=True, max_length=150),
),
]

View File

@@ -0,0 +1,50 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("pobsync_backend", "0012_review_state"),
]
operations = [
migrations.CreateModel(
name="PurgedSnapshot",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("host_name", models.CharField(max_length=255)),
("kind", models.CharField(max_length=16)),
("dirname", models.CharField(max_length=255)),
("path", models.CharField(max_length=1024)),
("reason", models.CharField(blank=True, max_length=512)),
(
"action",
models.CharField(
choices=[
("manual", "Manual"),
("scheduled", "Scheduled"),
("cli", "CLI"),
("incomplete_cleanup", "Incomplete cleanup"),
],
max_length=32,
),
),
("triggered_by", models.CharField(blank=True, max_length=150)),
("metadata", models.JSONField(blank=True, default=dict)),
("purged_at", models.DateTimeField(auto_now_add=True)),
(
"host",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="purged_snapshots",
to="pobsync_backend.hostconfig",
),
),
],
options={
"ordering": ["-purged_at", "host_name", "dirname"],
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.14 on 2026-05-22 22:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pobsync_backend', '0013_purgedsnapshot'),
]
operations = [
migrations.AddField(
model_name='hostconfig',
name='rsync_bwlimit_kbps',
field=models.PositiveIntegerField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,54 @@
# Generated by Django 5.2.14 on 2026-05-28 19:11
import django.db.models.deletion
import pobsync_backend.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pobsync_backend', '0014_host_bwlimit_override'),
]
operations = [
migrations.CreateModel(
name='NotificationTarget',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=128, unique=True)),
('enabled', models.BooleanField(default=True)),
('channel', models.CharField(choices=[('email', 'Email'), ('webhook', 'Webhook')], max_length=16)),
('statuses', models.JSONField(blank=True, default=pobsync_backend.models.default_notification_statuses)),
('email_to', models.TextField(blank=True)),
('webhook_url', models.URLField(blank=True, max_length=1024)),
('webhook_headers', models.JSONField(blank=True, default=dict)),
('notes', models.TextField(blank=True)),
('last_status', models.CharField(blank=True, max_length=16)),
('last_error', models.TextField(blank=True)),
('last_sent_at', models.DateTimeField(blank=True, null=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='NotificationDelivery',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('sent', 'Sent'), ('failed', 'Failed'), ('skipped', 'Skipped')], max_length=16)),
('error', models.TextField(blank=True)),
('payload', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_deliveries', to='pobsync_backend.backuprun')),
('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deliveries', to='pobsync_backend.notificationtarget')),
],
options={
'verbose_name_plural': 'notification deliveries',
'ordering': ['-created_at', 'target__name'],
'constraints': [models.UniqueConstraint(fields=('target', 'run'), name='unique_notification_delivery_per_target_run')],
},
),
]

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,275 @@
from __future__ import annotations
from django.db import models
class TimestampedModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
class GlobalConfig(TimestampedModel):
name = models.CharField(max_length=64, default="default", unique=True)
backup_root = models.CharField(max_length=512)
default_ssh_credential = models.ForeignKey(
"SshCredential",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="global_configs",
)
ssh_user = models.CharField(max_length=64, default="root")
ssh_port = models.PositiveIntegerField(default=22)
ssh_options = models.JSONField(default=list, blank=True)
rsync_binary = models.CharField(max_length=128, default="rsync")
rsync_args = models.JSONField(default=list, blank=True)
rsync_extra_args = models.JSONField(default=list, blank=True)
rsync_timeout_seconds = models.PositiveIntegerField(default=0)
rsync_bwlimit_kbps = models.PositiveIntegerField(default=0)
default_source_root = models.CharField(max_length=512, default="/")
default_destination_subdir = models.CharField(max_length=512, default="", blank=True)
excludes_default = models.JSONField(default=list, blank=True)
retention_daily = models.PositiveIntegerField(default=14)
retention_weekly = models.PositiveIntegerField(default=8)
retention_monthly = models.PositiveIntegerField(default=12)
retention_yearly = models.PositiveIntegerField(default=0)
class Meta:
verbose_name = "global config"
verbose_name_plural = "global configs"
def __str__(self) -> str:
return self.name
class HostConfig(TimestampedModel):
host = models.CharField(max_length=255, unique=True)
address = models.CharField(max_length=255)
enabled = models.BooleanField(default=True)
ssh_credential = models.ForeignKey(
"SshCredential",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="hosts",
)
ssh_user = models.CharField(max_length=64, blank=True)
ssh_port = models.PositiveIntegerField(null=True, blank=True)
source_root = models.CharField(max_length=512, blank=True)
includes = models.JSONField(default=list, blank=True)
excludes_add = models.JSONField(default=list, blank=True)
excludes_replace = models.JSONField(null=True, blank=True)
rsync_extra_args = models.JSONField(default=list, blank=True)
rsync_bwlimit_kbps = models.PositiveIntegerField(null=True, blank=True)
retention_daily = models.PositiveIntegerField(default=14)
retention_weekly = models.PositiveIntegerField(default=8)
retention_monthly = models.PositiveIntegerField(default=12)
retention_yearly = models.PositiveIntegerField(default=0)
config = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["host"]
def __str__(self) -> str:
return self.host
class SshCredential(TimestampedModel):
name = models.CharField(max_length=128, unique=True)
private_key = models.TextField(blank=True, default="")
public_key = models.TextField(blank=True)
key_path = models.CharField(max_length=1024, blank=True)
key_type = models.CharField(max_length=32, default="ed25519")
fingerprint = models.CharField(max_length=255, blank=True)
generated = models.BooleanField(default=False)
known_hosts = models.TextField(blank=True)
notes = models.TextField(blank=True)
class Meta:
ordering = ["name"]
def __str__(self) -> str:
return self.name
class BackupRun(models.Model):
class RunType(models.TextChoices):
SCHEDULED = "scheduled", "Scheduled"
MANUAL = "manual", "Manual"
class Status(models.TextChoices):
QUEUED = "queued", "Queued"
RUNNING = "running", "Running"
SUCCESS = "success", "Success"
WARNING = "warning", "Warning"
FAILED = "failed", "Failed"
CANCELLED = "cancelled", "Cancelled"
host = models.ForeignKey(HostConfig, on_delete=models.PROTECT, related_name="runs")
run_type = models.CharField(max_length=16, choices=RunType.choices, default=RunType.SCHEDULED)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.QUEUED)
started_at = models.DateTimeField(null=True, blank=True)
ended_at = models.DateTimeField(null=True, blank=True)
snapshot_path = models.CharField(max_length=1024, blank=True)
snapshot = models.ForeignKey(
"SnapshotRecord",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="backup_runs",
)
base_path = models.CharField(max_length=1024, blank=True)
rsync_exit_code = models.IntegerField(null=True, blank=True)
result = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
reviewed_at = models.DateTimeField(null=True, blank=True)
reviewed_by = models.CharField(max_length=150, blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.host} {self.run_type} {self.status}"
def default_notification_statuses() -> list[str]:
return [
BackupRun.Status.SUCCESS,
BackupRun.Status.WARNING,
BackupRun.Status.FAILED,
BackupRun.Status.CANCELLED,
]
class NotificationTarget(TimestampedModel):
class Channel(models.TextChoices):
EMAIL = "email", "Email"
WEBHOOK = "webhook", "Webhook"
name = models.CharField(max_length=128, unique=True)
enabled = models.BooleanField(default=True)
channel = models.CharField(max_length=16, choices=Channel.choices)
statuses = models.JSONField(default=default_notification_statuses, blank=True)
email_to = models.TextField(blank=True)
webhook_url = models.URLField(max_length=1024, blank=True)
webhook_headers = models.JSONField(default=dict, blank=True)
notes = models.TextField(blank=True)
last_status = models.CharField(max_length=16, blank=True)
last_error = models.TextField(blank=True)
last_sent_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["name"]
def __str__(self) -> str:
return self.name
class NotificationDelivery(models.Model):
class Status(models.TextChoices):
SENT = "sent", "Sent"
FAILED = "failed", "Failed"
SKIPPED = "skipped", "Skipped"
target = models.ForeignKey(NotificationTarget, on_delete=models.CASCADE, related_name="deliveries")
run = models.ForeignKey(BackupRun, on_delete=models.CASCADE, related_name="notification_deliveries")
status = models.CharField(max_length=16, choices=Status.choices)
error = models.TextField(blank=True)
payload = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=["target", "run"], name="unique_notification_delivery_per_target_run"),
]
ordering = ["-created_at", "target__name"]
verbose_name_plural = "notification deliveries"
def __str__(self) -> str:
return f"{self.target} run {self.run_id} {self.status}"
class SnapshotRecord(models.Model):
class Kind(models.TextChoices):
SCHEDULED = "scheduled", "Scheduled"
MANUAL = "manual", "Manual"
INCOMPLETE = "incomplete", "Incomplete"
host = models.ForeignKey(HostConfig, on_delete=models.CASCADE, related_name="snapshots")
kind = models.CharField(max_length=16, choices=Kind.choices)
dirname = models.CharField(max_length=255)
path = models.CharField(max_length=1024)
base = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="derived_snapshots",
)
base_kind = models.CharField(max_length=16, blank=True)
base_dirname = models.CharField(max_length=255, blank=True)
base_path = models.CharField(max_length=1024, blank=True)
base_snapshot_id = models.CharField(max_length=64, blank=True)
status = models.CharField(max_length=32, blank=True)
started_at = models.DateTimeField(null=True, blank=True)
ended_at = models.DateTimeField(null=True, blank=True)
metadata = models.JSONField(default=dict, blank=True)
discovered_at = models.DateTimeField(auto_now_add=True)
reviewed_at = models.DateTimeField(null=True, blank=True)
reviewed_by = models.CharField(max_length=150, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=["host", "kind", "dirname"], name="unique_snapshot_per_host_kind"),
]
ordering = ["host__host", "-started_at", "dirname"]
def __str__(self) -> str:
return f"{self.host}/{self.kind}/{self.dirname}"
class PurgedSnapshot(models.Model):
class Action(models.TextChoices):
MANUAL = "manual", "Manual"
SCHEDULED = "scheduled", "Scheduled"
CLI = "cli", "CLI"
INCOMPLETE_CLEANUP = "incomplete_cleanup", "Incomplete cleanup"
host = models.ForeignKey(HostConfig, on_delete=models.SET_NULL, null=True, blank=True, related_name="purged_snapshots")
host_name = models.CharField(max_length=255)
kind = models.CharField(max_length=16)
dirname = models.CharField(max_length=255)
path = models.CharField(max_length=1024)
reason = models.CharField(max_length=512, blank=True)
action = models.CharField(max_length=32, choices=Action.choices)
triggered_by = models.CharField(max_length=150, blank=True)
metadata = models.JSONField(default=dict, blank=True)
purged_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-purged_at", "host_name", "dirname"]
def __str__(self) -> str:
return f"{self.host_name}/{self.kind}/{self.dirname}"
class ScheduleConfig(TimestampedModel):
host = models.OneToOneField(HostConfig, on_delete=models.CASCADE, related_name="schedule")
cron_expr = models.CharField(max_length=128)
enabled = models.BooleanField(default=True)
prune = models.BooleanField(default=False)
prune_max_delete = models.PositiveIntegerField(default=10)
prune_protect_bases = models.BooleanField(default=False)
last_due_key = models.CharField(max_length=32, blank=True)
last_started_at = models.DateTimeField(null=True, blank=True)
last_finished_at = models.DateTimeField(null=True, blank=True)
last_status = models.CharField(max_length=16, blank=True)
class Meta:
ordering = ["host__host"]
def __str__(self) -> str:
return f"{self.host} {self.cron_expr}"

View File

@@ -0,0 +1,168 @@
from __future__ import annotations
import json
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any
from django.conf import settings
from django.core.mail import send_mail
from django.utils import timezone
from .models import BackupRun, NotificationDelivery, NotificationTarget
TERMINAL_RUN_STATUSES = {
BackupRun.Status.SUCCESS,
BackupRun.Status.WARNING,
BackupRun.Status.FAILED,
BackupRun.Status.CANCELLED,
}
@dataclass(frozen=True)
class DeliveryResult:
target: NotificationTarget
delivery: NotificationDelivery
sent: bool
def notify_backup_run_completed(run: BackupRun) -> list[DeliveryResult]:
if run.status not in TERMINAL_RUN_STATUSES:
return []
targets = [target for target in NotificationTarget.objects.filter(enabled=True) if _target_wants_status(target, run.status)]
return [_notify_target(target=target, run=run) for target in targets]
def _target_wants_status(target: NotificationTarget, status: str) -> bool:
statuses = target.statuses
if not isinstance(statuses, list):
return False
return status in {str(item) for item in statuses}
def _notify_target(*, target: NotificationTarget, run: BackupRun) -> DeliveryResult:
payload = _run_payload(run)
delivery, created = NotificationDelivery.objects.get_or_create(
target=target,
run=run,
defaults={
"status": NotificationDelivery.Status.SKIPPED,
"payload": payload,
},
)
if not created:
return DeliveryResult(target=target, delivery=delivery, sent=False)
try:
if target.channel == NotificationTarget.Channel.EMAIL:
_send_email(target=target, run=run, payload=payload)
elif target.channel == NotificationTarget.Channel.WEBHOOK:
_send_webhook(target=target, payload=payload)
else:
raise ValueError(f"Unsupported notification channel: {target.channel}")
except Exception as exc:
delivery.status = NotificationDelivery.Status.FAILED
delivery.error = str(exc)
delivery.save(update_fields=["status", "error"])
target.last_status = NotificationDelivery.Status.FAILED
target.last_error = str(exc)
target.save(update_fields=["last_status", "last_error", "updated_at"])
return DeliveryResult(target=target, delivery=delivery, sent=False)
delivery.status = NotificationDelivery.Status.SENT
delivery.save(update_fields=["status"])
target.last_status = NotificationDelivery.Status.SENT
target.last_error = ""
target.last_sent_at = timezone.now()
target.save(update_fields=["last_status", "last_error", "last_sent_at", "updated_at"])
return DeliveryResult(target=target, delivery=delivery, sent=True)
def _send_email(*, target: NotificationTarget, run: BackupRun, payload: dict[str, Any]) -> None:
recipients = [line.strip() for line in target.email_to.replace(",", "\n").splitlines() if line.strip()]
if not recipients:
raise ValueError("Email notification target has no recipients.")
subject = f"pobsync {run.status}: {run.host.host} run {run.id}"
message = _email_message(payload)
from_email = getattr(settings, "DEFAULT_FROM_EMAIL", "") or "pobsync@localhost"
sent = send_mail(subject, message, from_email, recipients, fail_silently=False)
if sent == 0:
raise ValueError("Django email backend reported zero sent messages.")
def _send_webhook(*, target: NotificationTarget, payload: dict[str, Any]) -> None:
if not target.webhook_url:
raise ValueError("Webhook notification target has no URL.")
headers = {"Content-Type": "application/json", **_string_headers(target.webhook_headers)}
request = urllib.request.Request(
target.webhook_url,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
if response.status >= 400:
raise ValueError(f"Webhook returned HTTP {response.status}.")
except urllib.error.HTTPError as exc:
raise ValueError(f"Webhook returned HTTP {exc.code}.") from exc
def _string_headers(headers: object) -> dict[str, str]:
if not isinstance(headers, dict):
return {}
return {str(key): str(value) for key, value in headers.items() if str(key).strip()}
def _run_payload(run: BackupRun) -> dict[str, Any]:
result = run.result if isinstance(run.result, dict) else {}
failure = result.get("failure") if isinstance(result.get("failure"), dict) else {}
prune = result.get("prune") if isinstance(result.get("prune"), dict) else {}
return {
"event": "backup_run.completed",
"run": {
"id": run.id,
"host": run.host.host,
"type": run.run_type,
"status": run.status,
"started_at": run.started_at.isoformat() if run.started_at else None,
"ended_at": run.ended_at.isoformat() if run.ended_at else None,
"snapshot": run.snapshot_path,
"rsync_exit_code": run.rsync_exit_code,
},
"failure": {
"category": failure.get("category"),
"message": failure.get("message") or result.get("error"),
"hint": failure.get("hint"),
},
"prune": {
"ok": prune.get("ok") if prune else None,
"error": prune.get("error") if prune else "",
},
}
def _email_message(payload: dict[str, Any]) -> str:
run = payload["run"]
lines = [
f"Host: {run['host']}",
f"Run: {run['id']}",
f"Type: {run['type']}",
f"Status: {run['status']}",
f"Started: {run['started_at'] or '-'}",
f"Ended: {run['ended_at'] or '-'}",
f"Snapshot: {run['snapshot'] or '-'}",
f"Rsync exit code: {run['rsync_exit_code'] if run['rsync_exit_code'] is not None else '-'}",
]
failure = payload.get("failure") if isinstance(payload.get("failure"), dict) else {}
if failure.get("message"):
lines.extend(["", f"Failure: {failure['message']}"])
prune = payload.get("prune") if isinstance(payload.get("prune"), dict) else {}
if prune.get("error"):
lines.extend(["", f"Retention: {prune['error']}"])
return "\n".join(lines)

View File

@@ -0,0 +1,232 @@
from __future__ import annotations
import shlex
import subprocess
from dataclasses import dataclass
from typing import Any
from pobsync.config.merge import build_effective_config
from pobsync.rsync import build_ssh_command
from .config_repository import global_config_object_data, host_config_object_data
from .config_source import DjangoConfigSource
from .host_ops import collect_host_checks
from .models import GlobalConfig, HostConfig
from .self_check import SelfCheck
DRY_RUN_BLOCKING_CHECKS = {
"Host global config",
"Host address",
"Host SSH key file",
"Host effective source root",
"Host effective SSH user",
"Host effective SSH port",
"Host effective SSH credential",
"Host effective rsync recursion",
}
@dataclass(frozen=True)
class BackupGate:
state: str
message: str
checks: list[SelfCheck]
real_blockers: list[SelfCheck]
dry_run_blockers: list[SelfCheck]
warnings: list[SelfCheck]
@property
def can_queue_real(self) -> bool:
return not self.real_blockers
@property
def can_queue_dry_run(self) -> bool:
return not self.dry_run_blockers
def collect_backup_gate(host: HostConfig, global_config: GlobalConfig | None = None) -> BackupGate:
checks = collect_host_checks(host, global_config)
remote_preflight_check = _remote_preflight_self_check(host)
if remote_preflight_check is not None:
checks.append(remote_preflight_check)
real_blockers = [check for check in checks if check.status == "failed"]
dry_run_blockers = [check for check in real_blockers if check.name in DRY_RUN_BLOCKING_CHECKS]
warnings = [check for check in checks if check.status == "warning"]
if real_blockers:
state = "blocked"
message = "Real backups are blocked until failed host checks are resolved."
elif warnings:
state = "warning"
message = "Backups can run, but review the warnings first."
else:
state = "ready"
message = "This host is ready for backup runs."
return BackupGate(
state=state,
message=message,
checks=checks,
real_blockers=real_blockers,
dry_run_blockers=dry_run_blockers,
warnings=warnings,
)
def run_remote_preflight(host: HostConfig, *, timeout_seconds: int = 20) -> dict[str, Any]:
config = DjangoConfigSource().effective_config_for_host(host.host)
ssh_cfg = config.get("ssh", {}) or {}
rsync_cfg = config.get("rsync", {}) or {}
address = str(config.get("address") or host.address)
user = str(ssh_cfg.get("user") or "root")
source_root = str(config.get("source_root") or (config.get("defaults", {}) or {}).get("source_root") or "/")
rsync_binary = str(rsync_cfg.get("binary") or "rsync")
target = f"{user}@{address}"
ssh_cmd = build_ssh_command(ssh_cfg)
checks = [
_run_remote_check(
name="SSH reachability",
command=[*ssh_cmd, "-oBatchMode=yes", target, "true"],
timeout_seconds=timeout_seconds,
),
_run_remote_check(
name="Remote rsync",
command=[
*ssh_cmd,
"-oBatchMode=yes",
target,
_remote_shell_command(f"command -v {shlex.quote(rsync_binary)} >/dev/null"),
],
timeout_seconds=timeout_seconds,
),
_run_remote_check(
name="Remote source root",
command=[
*ssh_cmd,
"-oBatchMode=yes",
target,
_remote_shell_command(f"test -e {shlex.quote(source_root)} && test -r {shlex.quote(source_root)}"),
],
timeout_seconds=timeout_seconds,
),
]
result = {
"ok": all(check["ok"] for check in checks),
"checks": checks,
"target": target,
"source_root": source_root,
"rsync_binary": rsync_binary,
"timeout_seconds": timeout_seconds,
}
host.config = {**(host.config or {}), "last_preflight": result}
host.save(update_fields=["config", "updated_at"])
return result
def _remote_shell_command(script: str) -> str:
return f"sh -lc {shlex.quote(script)}"
def effective_host_config_preview(host: HostConfig, global_config: GlobalConfig) -> dict[str, Any]:
config = build_effective_config(global_config_object_data(global_config), host_config_object_data(host))
credential = host.ssh_credential or global_config.default_ssh_credential
ssh = config.get("ssh", {}) or {}
rsync = config.get("rsync", {}) or {}
retention = config.get("retention", {}) or {}
return {
"source_root": config.get("source_root", ""),
"destination_subdir": (config.get("defaults", {}) or {}).get("destination_subdir", ""),
"includes": list(config.get("includes") or []),
"excludes": list(config.get("excludes_effective") or []),
"ssh": {
"user": ssh.get("user", ""),
"port": ssh.get("port", ""),
"options": list(ssh.get("options") or []),
"credential": str(credential) if credential else "",
},
"rsync": {
"binary": rsync.get("binary", ""),
"args": list(rsync.get("args_effective") or []),
"timeout_seconds": rsync.get("timeout_seconds", 0),
"bwlimit_kbps": rsync.get("bwlimit_kbps", 0),
},
"retention": {
"daily": retention.get("daily", 0),
"weekly": retention.get("weekly", 0),
"monthly": retention.get("monthly", 0),
"yearly": retention.get("yearly", 0),
},
}
def _run_remote_check(*, name: str, command: list[str], timeout_seconds: int) -> dict[str, Any]:
try:
result = subprocess.run(
command,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=timeout_seconds,
)
except subprocess.TimeoutExpired as exc:
return {
"name": name,
"ok": False,
"exit_code": 124,
"message": f"{name} timed out after {timeout_seconds}s.",
"detail": _clip_output((exc.stderr or exc.stdout or "").strip()),
}
except OSError as exc:
return {
"name": name,
"ok": False,
"exit_code": None,
"message": f"{name} could not start.",
"detail": str(exc),
}
return {
"name": name,
"ok": result.returncode == 0,
"exit_code": result.returncode,
"message": f"{name} passed." if result.returncode == 0 else f"{name} failed.",
"detail": _clip_output((result.stderr or result.stdout or "").strip()),
}
def _remote_preflight_self_check(host: HostConfig) -> SelfCheck | None:
preflight = (host.config or {}).get("last_preflight")
if not isinstance(preflight, dict):
return SelfCheck(
"Remote preflight",
"warning",
"No remote connection preflight has been run yet.",
"Run connection preflight before the first real backup.",
)
checks = preflight.get("checks")
if not isinstance(checks, list):
return SelfCheck("Remote preflight", "failed", "Stored remote preflight result is invalid.")
failed = [str(check.get("name", "unknown")) for check in checks if isinstance(check, dict) and not check.get("ok")]
if failed:
return SelfCheck(
"Remote preflight",
"failed",
"Remote connection preflight failed.",
", ".join(failed),
)
return SelfCheck(
"Remote preflight",
"ok",
"Remote connection preflight passed.",
f"{preflight.get('target', '')} {preflight.get('source_root', '')}".strip(),
)
def _clip_output(value: str, *, max_chars: int = 800) -> str:
if len(value) <= max_chars:
return value
return f"{value[:max_chars]}..."

View File

@@ -0,0 +1,431 @@
from __future__ import annotations
import shutil
import stat
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from pobsync.errors import ConfigError
from pobsync.lock import acquire_host_lock
from pobsync.paths import PobsyncPaths
from pobsync.retention import Snapshot, apply_base_protection, build_retention_plan
from pobsync.util import sanitize_host
from .models import HostConfig, PurgedSnapshot, SnapshotRecord
def run_sql_retention_plan(*, host: str, kind: str, protect_bases: bool) -> dict[str, Any]:
host = sanitize_host(host)
if kind not in {"scheduled", "manual", "all"}:
raise ConfigError("kind must be scheduled, manual, or all")
host_config = _enabled_host_config(host)
retention = _retention_for_host(host_config)
snapshots = _snapshots_for_retention(host_config=host_config, kind=kind)
incomplete_items = _incomplete_snapshot_items_for_host(host_config)
plan = build_retention_plan(
snapshots=snapshots,
retention=retention,
now=datetime.now(timezone.utc),
)
keep = set(plan.keep)
reasons = dict(plan.reasons)
if protect_bases:
keep, reasons = apply_base_protection(snapshots=snapshots, keep=keep, reasons=reasons)
delete = [snapshot for snapshot in snapshots if snapshot.dirname not in keep]
keep_items = [snapshot for snapshot in snapshots if snapshot.dirname in keep]
return {
"ok": True,
"host": host,
"kind": kind,
"protect_bases": bool(protect_bases),
"retention": retention,
"source": "sql",
"keep": sorted(keep),
"keep_items": [_snapshot_to_item(snapshot, reasons=reasons.get(snapshot.dirname, [])) for snapshot in keep_items],
"delete": [_snapshot_to_item(snapshot, reasons=["outside retention policy"]) for snapshot in delete],
"incomplete": incomplete_items,
"incomplete_reviewed_count": sum(1 for item in incomplete_items if item["reviewed"]),
"incomplete_unreviewed_count": sum(1 for item in incomplete_items if not item["reviewed"]),
"reasons": reasons,
}
def run_sql_retention_apply(
*,
prefix: Path,
host: str,
kind: str,
protect_bases: bool,
yes: bool,
max_delete: int,
action: str = PurgedSnapshot.Action.MANUAL,
triggered_by: str = "",
acquire_lock: bool = True,
) -> dict[str, Any]:
host = sanitize_host(host)
if not yes:
raise ConfigError("Refusing to delete snapshots without --yes")
if max_delete < 0:
raise ConfigError("--max-delete must be >= 0")
paths = PobsyncPaths(home=prefix)
def _do_apply() -> dict[str, Any]:
plan = run_sql_retention_plan(host=host, kind=kind, protect_bases=bool(protect_bases))
delete_list = plan.get("delete") or []
incomplete_list = plan.get("incomplete") or []
if not isinstance(delete_list, list):
raise ConfigError("Invalid retention plan output: delete is not a list")
if not isinstance(incomplete_list, list):
raise ConfigError("Invalid retention plan output: incomplete is not a list")
if max_delete == 0 and len(delete_list) > 0:
raise ConfigError("Deletion blocked by --max-delete=0")
if len(delete_list) > max_delete:
raise ConfigError(f"Refusing to delete {len(delete_list)} snapshots (exceeds --max-delete={max_delete})")
actions: list[str] = []
deleted: list[dict[str, Any]] = []
for item in delete_list:
dirname = item.get("dirname") if isinstance(item, dict) else None
snap_kind = item.get("kind") if isinstance(item, dict) else None
snap_path = item.get("path") if isinstance(item, dict) else None
if not isinstance(dirname, str) or not isinstance(snap_kind, str) or not isinstance(snap_path, str):
continue
if snap_kind not in {"scheduled", "manual"}:
raise ConfigError(f"Refusing to delete unsupported snapshot kind: {snap_kind!r}")
path = _snapshot_delete_path(path=Path(snap_path), dirname=dirname)
_validate_snapshot_delete_path(host=host, kind=snap_kind, path=path, dirname=dirname)
reason = str(item.get("reason") or "outside retention policy")
if not path.exists():
actions.append(f"skip missing {snap_kind}/{dirname}")
continue
if not path.is_dir():
raise ConfigError(f"Refusing to delete non-directory path: {path}")
_remove_snapshot_tree(path)
_record_purged_snapshot(
host_config=_enabled_host_config(host),
kind=snap_kind,
dirname=dirname,
path=path,
reason=reason,
action=action,
triggered_by=triggered_by,
metadata={"source": "retention", "protect_bases": bool(protect_bases), "retention_kind": kind},
)
SnapshotRecord.objects.filter(host__host=host, kind=snap_kind, dirname=dirname).delete()
actions.append(f"deleted {snap_kind} {dirname}")
deleted.append({"dirname": dirname, "kind": snap_kind, "path": str(path), "reason": reason})
return {
"ok": True,
"host": host,
"kind": kind,
"protect_bases": bool(protect_bases),
"max_delete": max_delete,
"source": "sql",
"planned_delete_count": len(delete_list),
"incomplete_ignored_count": len(incomplete_list),
"deleted": deleted,
"actions": actions,
}
if acquire_lock:
with acquire_host_lock(paths.locks_dir, host, command="retention-apply"):
return _do_apply()
return _do_apply()
def run_incomplete_cleanup(
*,
prefix: Path,
host: str,
yes: bool,
max_delete: int,
triggered_by: str = "",
acquire_lock: bool = True,
) -> dict[str, Any]:
host = sanitize_host(host)
if not yes:
raise ConfigError("Refusing to delete incomplete snapshots without --yes")
if max_delete < 0:
raise ConfigError("--max-delete must be >= 0")
paths = PobsyncPaths(home=prefix)
def _do_cleanup() -> dict[str, Any]:
host_config = _enabled_host_config(host)
unreviewed_count = _unreviewed_incomplete_count(host_config)
if unreviewed_count:
raise ConfigError(
f"Refusing to delete {unreviewed_count} incomplete snapshot(s) that have not been reviewed."
)
incomplete_list = [
_snapshot_to_item(snapshot, reasons=["manual incomplete cleanup"])
for snapshot in _reviewed_incomplete_snapshots_for_host(host_config)
]
if max_delete == 0 and len(incomplete_list) > 0:
raise ConfigError("Incomplete cleanup blocked by --max-delete=0")
if len(incomplete_list) > max_delete:
raise ConfigError(
f"Refusing to delete {len(incomplete_list)} incomplete snapshots (exceeds --max-delete={max_delete})"
)
actions: list[str] = []
deleted: list[dict[str, Any]] = []
for item in incomplete_list:
dirname = item["dirname"]
snap_path = Path(item["path"])
path = _snapshot_delete_path(path=snap_path, dirname=dirname)
_validate_incomplete_delete_path(host=host, path=path, dirname=dirname)
if not path.exists():
actions.append(f"skip missing incomplete/{dirname}")
elif not path.is_dir():
raise ConfigError(f"Refusing to delete non-directory path: {path}")
else:
_remove_snapshot_tree(path)
actions.append(f"deleted incomplete {dirname}")
_record_purged_snapshot(
host_config=host_config,
kind=SnapshotRecord.Kind.INCOMPLETE,
dirname=dirname,
path=path,
reason="manual incomplete cleanup",
action=PurgedSnapshot.Action.INCOMPLETE_CLEANUP,
triggered_by=triggered_by,
metadata={"source": "incomplete_cleanup"},
)
SnapshotRecord.objects.filter(
host__host=host,
kind=SnapshotRecord.Kind.INCOMPLETE,
dirname=dirname,
).delete()
deleted.append({"dirname": dirname, "kind": SnapshotRecord.Kind.INCOMPLETE, "path": str(path)})
return {
"ok": True,
"host": host,
"kind": SnapshotRecord.Kind.INCOMPLETE,
"max_delete": max_delete,
"source": "sql",
"planned_delete_count": len(incomplete_list),
"deleted": deleted,
"actions": actions,
}
if acquire_lock:
with acquire_host_lock(paths.locks_dir, host, command="incomplete-cleanup"):
return _do_cleanup()
return _do_cleanup()
def _enabled_host_config(host: str) -> HostConfig:
try:
return HostConfig.objects.get(host=host, enabled=True)
except HostConfig.DoesNotExist as exc:
raise ConfigError(f"Missing enabled host {host!r}") from exc
def _retention_for_host(host_config: HostConfig) -> dict[str, int]:
return {
"daily": host_config.retention_daily,
"weekly": host_config.retention_weekly,
"monthly": host_config.retention_monthly,
"yearly": host_config.retention_yearly,
}
def _snapshots_for_retention(*, host_config: HostConfig, kind: str) -> list[Snapshot]:
kinds = ["scheduled", "manual"] if kind == "all" else [kind]
records = (
SnapshotRecord.objects.filter(host=host_config, kind__in=kinds)
.exclude(kind=SnapshotRecord.Kind.INCOMPLETE)
.select_related("base")
.order_by("-started_at", "dirname")
)
return [_snapshot_from_record(record) for record in records]
def _incomplete_snapshot_items_for_host(host_config: HostConfig) -> list[dict[str, Any]]:
records = (
SnapshotRecord.objects.filter(host=host_config, kind=SnapshotRecord.Kind.INCOMPLETE)
.select_related("base")
.order_by("-started_at", "dirname")
)
return [
_snapshot_record_to_item(record, reasons=["incomplete snapshot; excluded from retention cleanup"])
for record in records
]
def _reviewed_incomplete_snapshots_for_host(host_config: HostConfig) -> list[Snapshot]:
records = (
SnapshotRecord.objects.filter(
host=host_config,
kind=SnapshotRecord.Kind.INCOMPLETE,
reviewed_at__isnull=False,
)
.select_related("base")
.order_by("-started_at", "dirname")
)
return [_snapshot_from_record(record) for record in records]
def _unreviewed_incomplete_count(host_config: HostConfig) -> int:
return SnapshotRecord.objects.filter(
host=host_config,
kind=SnapshotRecord.Kind.INCOMPLETE,
reviewed_at__isnull=True,
).count()
def _snapshot_from_record(record: SnapshotRecord) -> Snapshot:
return Snapshot(
kind=record.kind,
dirname=record.dirname,
path=record.path,
dt=record.started_at or datetime.fromtimestamp(0, tz=timezone.utc),
status=record.status or None,
base=_base_meta_from_record(record),
)
def _base_meta_from_record(record: SnapshotRecord) -> dict[str, str] | None:
if record.base is not None:
return {
"kind": record.base.kind,
"dirname": record.base.dirname,
"path": record.base.path,
}
if record.base_kind and record.base_dirname:
return {
"kind": record.base_kind,
"dirname": record.base_dirname,
"path": record.base_path,
}
return None
def _snapshot_to_item(snapshot: Snapshot, *, reasons: list[str]) -> dict[str, Any]:
return {
"dirname": snapshot.dirname,
"kind": snapshot.kind,
"path": snapshot.path,
"dt": snapshot.dt.isoformat(),
"status": snapshot.status,
"reasons": reasons,
"reason": ", ".join(reasons),
}
def _snapshot_record_to_item(record: SnapshotRecord, *, reasons: list[str]) -> dict[str, Any]:
item = _snapshot_to_item(_snapshot_from_record(record), reasons=reasons)
item["reviewed"] = record.reviewed_at is not None
item["reviewed_at"] = record.reviewed_at.isoformat() if record.reviewed_at else ""
item["reviewed_by"] = record.reviewed_by
return item
def _snapshot_delete_path(*, path: Path, dirname: str) -> Path:
if path.name == "data" and path.parent.name == dirname:
return path.parent
return path
def _record_purged_snapshot(
*,
host_config: HostConfig,
kind: str,
dirname: str,
path: Path,
reason: str,
action: str,
triggered_by: str,
metadata: dict[str, Any],
) -> None:
PurgedSnapshot.objects.create(
host=host_config,
host_name=host_config.host,
kind=kind,
dirname=dirname,
path=str(path),
reason=reason,
action=action,
triggered_by=triggered_by,
metadata=metadata,
)
def _validate_incomplete_delete_path(*, host: str, path: Path, dirname: str) -> None:
path_parts = path.parts
if path.name != dirname or ".incomplete" not in path_parts or host not in path_parts:
raise ConfigError(f"Refusing to delete unexpected incomplete snapshot path: {path}")
incomplete_index = path_parts.index(".incomplete")
if incomplete_index == 0 or path_parts[incomplete_index - 1] != host:
raise ConfigError(f"Refusing to delete incomplete snapshot outside host backup root: {path}")
def _validate_snapshot_delete_path(*, host: str, kind: str, path: Path, dirname: str) -> None:
if kind not in {SnapshotRecord.Kind.SCHEDULED, SnapshotRecord.Kind.MANUAL}:
raise ConfigError(f"Refusing to delete unsupported snapshot kind: {kind!r}")
path_parts = path.parts
if path.name != dirname or kind not in path_parts or host not in path_parts:
raise ConfigError(f"Refusing to delete unexpected snapshot path: {path}")
kind_index = path_parts.index(kind)
if kind_index == 0 or path_parts[kind_index - 1] != host:
raise ConfigError(f"Refusing to delete snapshot outside host backup root: {path}")
def _remove_snapshot_tree(path: Path) -> None:
_make_snapshot_tree_user_removable(path)
shutil.rmtree(path, onexc=_retry_remove_with_user_permissions)
def _make_snapshot_tree_user_removable(path: Path) -> None:
stack = [path]
while stack:
directory = stack.pop()
if directory.is_symlink():
continue
_make_path_user_removable(directory)
try:
children = list(directory.iterdir())
except OSError:
continue
for child in children:
if child.is_dir() and not child.is_symlink():
stack.append(child)
def _retry_remove_with_user_permissions(function: Any, path: str, excinfo: BaseException) -> None:
failed_path = Path(path)
_make_path_user_removable(failed_path)
function(path)
def _make_path_user_removable(path: Path) -> None:
try:
mode = path.stat().st_mode
except OSError:
return
wanted = stat.S_IRUSR | stat.S_IWUSR
if path.is_dir() and not path.is_symlink():
wanted |= stat.S_IXUSR
if mode & wanted == wanted:
return
try:
path.chmod(mode | wanted)
except OSError:
return

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
@dataclass(frozen=True)
class CronSchedule:
minute: str
hour: str
day_of_month: str
month: str
day_of_week: str
def parse_cron_expr(expr: str) -> CronSchedule:
parts = expr.strip().split()
if len(parts) != 5:
raise ValueError("cron expression must have exactly 5 fields")
return CronSchedule(*parts)
def due_key(moment: datetime) -> str:
return moment.strftime("%Y%m%d%H%M")
def is_due(expr: str, moment: datetime) -> bool:
schedule = parse_cron_expr(expr)
cron_dow = (moment.weekday() + 1) % 7
return (
_field_matches(schedule.minute, moment.minute, 0, 59)
and _field_matches(schedule.hour, moment.hour, 0, 23)
and _field_matches(schedule.day_of_month, moment.day, 1, 31)
and _field_matches(schedule.month, moment.month, 1, 12)
and _field_matches(schedule.day_of_week, cron_dow, 0, 7, sunday_alias=True)
)
def next_due_after(expr: str, moment: datetime, *, max_days: int = 366) -> datetime | None:
parse_cron_expr(expr)
candidate = moment.replace(second=0, microsecond=0) + timedelta(minutes=1)
deadline = candidate + timedelta(days=max_days)
while candidate <= deadline:
if is_due(expr, candidate):
return candidate
candidate += timedelta(minutes=1)
return None
def _field_matches(field: str, value: int, min_value: int, max_value: int, sunday_alias: bool = False) -> bool:
for part in field.split(","):
if _part_matches(part.strip(), value, min_value, max_value, sunday_alias=sunday_alias):
return True
return False
def _part_matches(part: str, value: int, min_value: int, max_value: int, sunday_alias: bool) -> bool:
if not part:
return False
step = 1
base = part
if "/" in part:
base, step_s = part.split("/", 1)
if not step_s.isdigit():
return False
step = int(step_s)
if step <= 0:
return False
if base == "*":
start = min_value
end = max_value
elif "-" in base:
start_s, end_s = base.split("-", 1)
if not start_s.isdigit() or not end_s.isdigit():
return False
start = int(start_s)
end = int(end_s)
elif base.isdigit():
start = end = int(base)
else:
return False
values = _normalize_values(range(start, end + 1), sunday_alias=sunday_alias)
normalized_value = 0 if sunday_alias and value == 7 else value
if normalized_value not in values:
return False
return (normalized_value - min(values)) % step == 0
def _normalize_values(values: range, sunday_alias: bool) -> set[int]:
out: set[int] = set()
for value in values:
if sunday_alias and value == 7:
out.add(0)
else:
out.add(value)
return out

View File

@@ -0,0 +1,329 @@
from __future__ import annotations
import os
import pwd
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from django.conf import settings
from django.db import connection
from .models import GlobalConfig
CheckStatus = Literal["ok", "warning", "failed", "skipped"]
@dataclass(frozen=True)
class SelfCheck:
name: str
status: CheckStatus
message: str
detail: str = ""
def collect_self_checks() -> list[SelfCheck]:
checks: list[SelfCheck] = []
checks.extend(_django_checks())
checks.extend(_install_checks())
checks.extend(_path_checks())
checks.extend(_binary_checks())
checks.extend(_database_checks())
checks.extend(_config_checks())
checks.extend(_systemd_checks())
return checks
def _native_runtime_available() -> bool:
return Path("/run/systemd/system").exists() and shutil.which("systemctl") is not None
def summarize_self_checks(checks: list[SelfCheck]) -> dict[str, int]:
return {
"ok": sum(1 for check in checks if check.status == "ok"),
"warning": sum(1 for check in checks if check.status == "warning"),
"failed": sum(1 for check in checks if check.status == "failed"),
"skipped": sum(1 for check in checks if check.status == "skipped"),
}
def _django_checks() -> list[SelfCheck]:
checks = [
SelfCheck(
"Django debug",
"warning" if settings.DEBUG else "ok",
"DEBUG is enabled." if settings.DEBUG else "DEBUG is disabled.",
),
SelfCheck(
"Django secret key",
"failed" if settings.SECRET_KEY == "dev-only-change-me" else "ok",
"Default development secret key is still active."
if settings.SECRET_KEY == "dev-only-change-me"
else "Secret key is configured.",
),
SelfCheck(
"Allowed hosts",
"ok" if settings.ALLOWED_HOSTS else "failed",
", ".join(settings.ALLOWED_HOSTS) if settings.ALLOWED_HOSTS else "No allowed hosts configured.",
),
]
return checks
def _path_checks() -> list[SelfCheck]:
checks = []
checks.append(
_path_check(
"State root",
Path(settings.POBSYNC_HOME),
must_be_absolute=True,
must_be_writable=True,
)
)
checks.append(
_path_check(
"Backup root",
Path(settings.POBSYNC_BACKUP_ROOT),
must_be_absolute=True,
must_exist=True,
must_be_writable=True,
)
)
checks.append(
_path_check(
"Static root",
Path(settings.STATIC_ROOT),
must_be_absolute=True,
must_exist=False,
must_be_writable=True,
)
)
db_settings = settings.DATABASES["default"]
if db_settings["ENGINE"] == "django.db.backends.sqlite3":
sqlite_path = Path(str(db_settings["NAME"]))
checks.append(
_path_check(
"SQLite directory",
sqlite_path.parent,
must_be_absolute=True,
must_exist=True,
must_be_writable=True,
)
)
checks.append(_sqlite_database_check(sqlite_path))
return checks
def _install_checks() -> list[SelfCheck]:
if not _native_runtime_available() and not Path(settings.POBSYNC_ENV_FILE).exists():
return [
SelfCheck(
"Environment file",
"skipped",
"Native environment file is not configured in this runtime.",
"This is expected inside Docker or local development.",
),
SelfCheck(
"Service user",
"skipped",
"Native service user check is not available in this runtime.",
"This is expected inside Docker or local development.",
),
SelfCheck(
"Backup root owner",
"skipped",
"Native backup root ownership check is not available in this runtime.",
"This is expected inside Docker or local development.",
),
]
checks = [_env_file_check(Path(settings.POBSYNC_ENV_FILE)), _service_user_check()]
checks.append(_backup_root_owner_check(Path(settings.POBSYNC_BACKUP_ROOT)))
return checks
def _env_file_check(path: Path) -> SelfCheck:
if not path.is_absolute():
return SelfCheck("Environment file", "failed", f"{path} is not absolute.")
if not path.exists():
return SelfCheck("Environment file", "failed", f"{path} does not exist.")
if not path.is_file():
return SelfCheck("Environment file", "failed", f"{path} is not a regular file.")
if not os.access(path, os.R_OK):
return SelfCheck("Environment file", "failed", f"{path} is not readable by this process.")
return SelfCheck("Environment file", "ok", str(path))
def _service_user_check() -> SelfCheck:
expected_user = settings.POBSYNC_SERVICE_USER
try:
current_user = pwd.getpwuid(os.geteuid()).pw_name
except KeyError:
return SelfCheck("Service user", "failed", f"Current uid {os.geteuid()} has no passwd entry.")
if current_user != expected_user:
return SelfCheck(
"Service user",
"warning",
f"Current process runs as {current_user}, expected {expected_user}.",
"Run terminal checks with sudo -u <service-user> pobsync-manage check_pobsync_install.",
)
return SelfCheck("Service user", "ok", current_user)
def _backup_root_owner_check(path: Path) -> SelfCheck:
if not path.exists():
return SelfCheck("Backup root owner", "failed", f"{path} does not exist.")
expected_user = settings.POBSYNC_SERVICE_USER
try:
owner = pwd.getpwuid(path.stat().st_uid).pw_name
except KeyError:
return SelfCheck("Backup root owner", "warning", f"{path} owner uid {path.stat().st_uid} has no passwd entry.")
if owner != expected_user:
return SelfCheck(
"Backup root owner",
"warning",
f"{path} is owned by {owner}, expected {expected_user}.",
)
return SelfCheck("Backup root owner", "ok", f"{path} owner={owner}")
def _sqlite_database_check(path: Path) -> SelfCheck:
if not path.is_absolute():
return SelfCheck("SQLite database", "failed", f"{path} is not absolute.")
if not path.exists():
return SelfCheck("SQLite database", "warning", f"{path} does not exist yet.")
if not path.is_file():
return SelfCheck("SQLite database", "failed", f"{path} is not a regular file.")
if not os.access(path, os.R_OK | os.W_OK):
return SelfCheck("SQLite database", "failed", f"{path} is not readable and writable by this process.")
return SelfCheck("SQLite database", "ok", str(path))
def _path_check(
name: str,
path: Path,
*,
must_be_absolute: bool,
must_exist: bool = False,
must_be_writable: bool,
) -> SelfCheck:
if must_be_absolute and not path.is_absolute():
return SelfCheck(name, "failed", f"{path} is not absolute.")
if must_exist and not path.exists():
return SelfCheck(name, "failed", f"{path} does not exist.")
target = path if path.exists() else path.parent
if not target.exists():
return SelfCheck(name, "failed", f"{target} does not exist.")
if must_be_writable and not os.access(target, os.W_OK):
return SelfCheck(name, "failed", f"{target} is not writable by this process.")
return SelfCheck(name, "ok", str(path))
def _binary_checks() -> list[SelfCheck]:
checks = []
for binary in ("rsync", "ssh", "ssh-keygen"):
path = shutil.which(binary)
checks.append(
SelfCheck(
f"Binary: {binary}",
"ok" if path else "failed",
path or f"{binary} was not found in PATH.",
)
)
gunicorn_path = shutil.which("gunicorn") or Path(sys.executable).parent / "gunicorn"
checks.append(
SelfCheck(
"Binary: gunicorn",
"ok" if Path(gunicorn_path).exists() else "failed",
str(gunicorn_path) if Path(gunicorn_path).exists() else "gunicorn was not found in PATH or next to Python.",
)
)
return checks
def _database_checks() -> list[SelfCheck]:
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
except Exception as exc:
return [SelfCheck("Database connection", "failed", f"{type(exc).__name__}: {exc}")]
return [SelfCheck("Database connection", "ok", settings.DATABASES["default"]["ENGINE"])]
def _config_checks() -> list[SelfCheck]:
try:
global_config = GlobalConfig.objects.get(name="default")
except GlobalConfig.DoesNotExist:
return [SelfCheck("Global config", "warning", "Default global config has not been created yet.")]
status: CheckStatus = "ok"
message = "Default global config exists."
if global_config.backup_root != settings.POBSYNC_BACKUP_ROOT:
status = "warning"
message = "Saved backup root differs from the active backup root."
return [
SelfCheck(
"Global config",
status,
message,
f"saved={global_config.backup_root} active={settings.POBSYNC_BACKUP_ROOT}",
)
]
def _systemd_checks() -> list[SelfCheck]:
if not _native_runtime_available():
return [
SelfCheck(
"Systemd services",
"skipped",
"systemd is not available in this runtime.",
"This is expected inside Docker.",
)
]
checks = []
for service in ("pobsync-web.service", "pobsync-worker.service", "pobsync-scheduler.service"):
result = subprocess.run(
["systemctl", "is-active", service],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=5,
)
active_state = result.stdout.strip() or result.stderr.strip()
checks.append(
SelfCheck(
service,
"ok" if result.returncode == 0 else "failed",
active_state,
)
)
if shutil.which("journalctl") is not None:
result = subprocess.run(
["journalctl", "--no-pager", "-n", "1", "-u", "pobsync-web.service"],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=5,
)
journal_error = result.stderr.strip()
journal_denied = "No journal files were opened" in journal_error or "permission" in journal_error.lower()
has_journal_access = result.returncode == 0 and not journal_denied
checks.append(
SelfCheck(
"Journal access",
"ok" if has_journal_access else "failed",
"pobsync can read service logs." if has_journal_access else "pobsync cannot read service logs.",
journal_error,
)
)
return checks

View File

@@ -0,0 +1,203 @@
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from pobsync.snapshot_meta import iter_snapshot_dirs, read_snapshot_meta, resolve_host_root
from .models import GlobalConfig, HostConfig, SnapshotRecord
def parse_snapshot_datetime(dirname: str, meta: dict[str, Any], key: str) -> datetime | None:
value = meta.get(key)
if isinstance(value, str):
parsed = _parse_iso_z(value)
if parsed is not None:
return parsed
if key == "started_at":
try:
prefix = dirname.split("__", 1)[0]
return datetime.strptime(prefix, "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
except ValueError:
return None
return None
def discover_snapshots(
*,
host: HostConfig | None = None,
global_config: GlobalConfig | None = None,
kinds: list[str] | None = None,
) -> dict[str, Any]:
global_config = global_config or GlobalConfig.objects.get(name="default")
host_qs = HostConfig.objects.filter(enabled=True).order_by("host")
if host is not None:
host_qs = host_qs.filter(pk=host.pk)
kinds = kinds or ["scheduled", "manual", "incomplete"]
scanned = 0
created = 0
updated = 0
for host_config in host_qs:
host_root = resolve_host_root(global_config.backup_root, host_config.host)
for kind in kinds:
for snapshot_dir in iter_snapshot_dirs(host_root, kind):
_record, was_created = upsert_snapshot_record(
host=host_config,
kind=kind,
snapshot_dir=snapshot_dir,
)
scanned += 1
if was_created:
created += 1
else:
updated += 1
resolve_base_links(host=host_config)
return {
"ok": True,
"scanned": scanned,
"created": created,
"updated": updated,
}
def inspect_snapshot_discovery(
*,
host: HostConfig,
global_config: GlobalConfig | None = None,
kinds: list[str] | None = None,
) -> dict[str, Any]:
try:
global_config = global_config or GlobalConfig.objects.get(name="default")
except GlobalConfig.DoesNotExist:
return {
"ok": False,
"reason": "missing_global_config",
"message": "Create the default global config before discovering snapshots.",
"backup_root": "",
"host_root": "",
"host_root_exists": False,
"kind_counts": {},
"total_candidates": 0,
}
kinds = kinds or ["scheduled", "manual", "incomplete"]
host_root = resolve_host_root(global_config.backup_root, host.host)
kind_counts = {kind: len(list(iter_snapshot_dirs(host_root, kind))) for kind in kinds}
total_candidates = sum(kind_counts.values())
host_root_exists = host_root.exists()
if not host_root_exists:
reason = "missing_host_root"
message = f"Host backup directory does not exist yet: {host_root}"
elif total_candidates == 0:
reason = "no_snapshots"
message = f"No snapshot directories found below {host_root}."
else:
reason = "ready"
message = f"Found {total_candidates} snapshot directories below {host_root}."
return {
"ok": True,
"reason": reason,
"message": message,
"backup_root": str(global_config.backup_root),
"host_root": str(host_root),
"host_root_exists": host_root_exists,
"kind_counts": kind_counts,
"total_candidates": total_candidates,
}
def upsert_snapshot_record(*, host: HostConfig, kind: str, snapshot_dir: Path) -> tuple[SnapshotRecord, bool]:
meta = read_snapshot_meta(snapshot_dir)
base_defaults = _base_defaults_from_meta(meta)
defaults = {
"path": str(snapshot_dir),
**base_defaults,
"base": _resolve_base_record(
host=host,
kind=base_defaults["base_kind"],
dirname=base_defaults["base_dirname"],
),
"status": str(meta.get("status") or ""),
"started_at": parse_snapshot_datetime(snapshot_dir.name, meta, "started_at"),
"ended_at": parse_snapshot_datetime(snapshot_dir.name, meta, "ended_at"),
"metadata": meta,
}
return SnapshotRecord.objects.update_or_create(
host=host,
kind=kind,
dirname=snapshot_dir.name,
defaults=defaults,
)
def resolve_base_links(*, host: HostConfig | None = None) -> int:
snapshot_qs = SnapshotRecord.objects.exclude(base_dirname="").filter(base__isnull=True)
if host is not None:
snapshot_qs = snapshot_qs.filter(host=host)
updated = 0
for snapshot in snapshot_qs.select_related("host"):
base = _resolve_base_record(
host=snapshot.host,
kind=snapshot.base_kind,
dirname=snapshot.base_dirname,
)
if base is None:
continue
snapshot.base = base
snapshot.save(update_fields=["base"])
updated += 1
return updated
def infer_snapshot_kind(snapshot_path: Path) -> str:
parent = snapshot_path.parent.name
if parent == "scheduled":
return "scheduled"
if parent == "manual":
return "manual"
if parent == ".incomplete":
return "incomplete"
raise ValueError(f"Cannot infer snapshot kind from path: {snapshot_path}")
def _base_defaults_from_meta(meta: dict[str, Any]) -> dict[str, Any]:
base = meta.get("base")
if not isinstance(base, dict):
base = {}
return {
"base_kind": _base_value(base.get("kind")),
"base_dirname": _base_value(base.get("dirname")),
"base_path": _base_value(base.get("path")),
"base_snapshot_id": _base_value(base.get("id")),
}
def _base_value(value: Any) -> str:
return value if isinstance(value, str) else ""
def _resolve_base_record(*, host: HostConfig, kind: str, dirname: str) -> SnapshotRecord | None:
if not kind or not dirname:
return None
return SnapshotRecord.objects.filter(host=host, kind=kind, dirname=dirname).first()
def _parse_iso_z(value: str) -> datetime | None:
try:
if value.endswith("Z"):
return datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed
except ValueError:
return None

View File

@@ -0,0 +1,145 @@
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
from django.conf import settings
from .models import SshCredential
class SshKeyError(RuntimeError):
pass
def credential_dir(credential: SshCredential) -> Path:
return Path(settings.POBSYNC_HOME) / "state" / "ssh-credentials" / str(credential.pk)
def identity_path(credential: SshCredential) -> Path:
if credential.key_path:
return Path(credential.key_path)
return credential_dir(credential) / "identity"
def generate_ssh_key(credential: SshCredential, *, key_type: str = "ed25519", force: bool = False) -> SshCredential:
if credential.pk is None:
raise SshKeyError("Credential must be saved before generating an SSH key.")
if shutil.which("ssh-keygen") is None:
raise SshKeyError("ssh-keygen is not available.")
key_dir = credential_dir(credential)
key_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(key_dir, 0o700)
private_key = key_dir / "identity"
public_key_file = key_dir / "identity.pub"
if force:
private_key.unlink(missing_ok=True)
public_key_file.unlink(missing_ok=True)
elif private_key.exists() or public_key_file.exists():
raise SshKeyError(f"SSH key already exists for {credential.name}.")
result = subprocess.run(
[
"ssh-keygen",
"-t",
key_type,
"-N",
"",
"-C",
f"pobsync:{credential.name}",
"-f",
str(private_key),
],
check=False,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=15,
)
if result.returncode != 0:
raise SshKeyError(result.stderr.strip() or "ssh-keygen failed.")
os.chmod(private_key, 0o600)
public_key = public_key_file.read_text(encoding="utf-8").strip()
fingerprint = fingerprint_for_key(private_key)
credential.private_key = ""
credential.public_key = public_key
credential.key_path = str(private_key)
credential.key_type = key_type
credential.fingerprint = fingerprint
credential.generated = True
credential.save(update_fields=["private_key", "public_key", "key_path", "key_type", "fingerprint", "generated", "updated_at"])
return credential
def fingerprint_for_key(private_key: Path) -> str:
result = subprocess.run(
["ssh-keygen", "-lf", str(private_key)],
check=False,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=5,
)
if result.returncode != 0:
raise SshKeyError(result.stderr.strip() or "Could not fingerprint SSH key.")
return result.stdout.strip()
def delete_generated_key_files(credential: SshCredential) -> None:
path = identity_path(credential)
allowed_root = (Path(settings.POBSYNC_HOME) / "state" / "ssh-credentials").resolve()
try:
resolved = path.resolve()
except FileNotFoundError:
resolved = path
if allowed_root not in resolved.parents:
raise SshKeyError(f"Refusing to delete key outside {allowed_root}.")
path.unlink(missing_ok=True)
path.with_suffix(path.suffix + ".pub").unlink(missing_ok=True)
if path.name == "identity":
(path.parent / "identity.pub").unlink(missing_ok=True)
def scan_known_host(address: str, *, port: int = 22, timeout: int = 5) -> str:
if shutil.which("ssh-keyscan") is None:
raise SshKeyError("ssh-keyscan is not available.")
command = ["ssh-keyscan", "-T", str(timeout), "-p", str(port), address]
result = subprocess.run(
command,
check=False,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=timeout + 2,
)
if result.returncode != 0 and not result.stdout.strip():
raise SshKeyError(result.stderr.strip() or f"Could not scan SSH host key for {address}.")
lines = [line.strip() for line in result.stdout.splitlines() if line.strip() and not line.startswith("#")]
if not lines:
raise SshKeyError(f"ssh-keyscan returned no host keys for {address}.")
return "\n".join(lines)
def merge_known_hosts(existing: str, scanned: str) -> str:
lines: list[str] = []
seen: set[str] = set()
for line in [*existing.splitlines(), *scanned.splitlines()]:
normalized = line.strip()
if not normalized or normalized in seen:
continue
seen.add(normalized)
lines.append(normalized)
return "\n".join(lines) + ("\n" if lines else "")

View File

@@ -0,0 +1,307 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Iterable
from django.utils import timezone
from pobsync.run_stats import filesystem_capacity, tree_usage
from .models import BackupRun, GlobalConfig, HostConfig, SnapshotRecord
def collect_dashboard_stats(*, hosts: Iterable[HostConfig], global_config: GlobalConfig | None) -> dict[str, Any]:
hosts = list(hosts)
runs = list(
BackupRun.objects.select_related("host", "snapshot")
.filter(status__in=_COMPLETED_BACKUP_STATUSES)
.order_by("-started_at", "-created_at")[:100]
)
real_runs = [_run_summary(run) for run in runs if _is_real_run(run)]
real_runs = [run for run in real_runs if run["has_stats"]]
for host in hosts:
host.stats_summary = collect_host_stats(host=host)
backup_data = _sum_backup_data_by_kind(host.stats_summary["backup_data"] for host in hosts)
literal_values = [_int_at(run, "rsync", "literal_data_bytes") for run in real_runs]
literal_values = [value for value in literal_values if value is not None]
matched_values = [_int_at(run, "rsync", "matched_data_bytes") for run in real_runs]
matched_values = [value for value in matched_values if value is not None]
duration_values = [_int_at(run, "duration_seconds") for run in real_runs]
duration_values = [value for value in duration_values if value is not None]
avg_literal = _average(literal_values)
total_literal = sum(literal_values)
total_matched = sum(matched_values)
savings_basis = total_literal + total_matched
capacity = _capacity_from_system(global_config) or _latest_capacity_from_runs(real_runs) or {}
available = _int_at(capacity, "available_bytes")
daily_literal = _average_daily_literal(real_runs)
link_dest_savings_ratio = round(total_matched / savings_basis, 4) if savings_basis else None
return {
"runs_sampled": len(real_runs),
"avg_duration_seconds": _average(duration_values),
"avg_daily_literal_data_bytes": daily_literal,
"avg_literal_data_bytes": avg_literal,
"total_literal_data_bytes": total_literal,
"total_matched_data_bytes": total_matched,
"link_dest_savings_ratio": link_dest_savings_ratio,
"link_dest_savings_percent": round(link_dest_savings_ratio * 100, 1) if link_dest_savings_ratio is not None else None,
"estimated_runs_until_full": int(available / avg_literal) if available and avg_literal else None,
"estimated_days_until_full": int(available / daily_literal) if available and daily_literal else None,
"capacity": capacity,
"backup_data": backup_data,
}
def collect_host_stats(*, host: HostConfig, limit: int = 8) -> dict[str, Any]:
runs = list(host.runs.select_related("snapshot").order_by("-started_at", "-created_at")[:50])
real_runs = [_run_summary(run) for run in runs if _is_real_run(run)]
completed_real_runs = [run for run in real_runs if run["status"] in _COMPLETED_BACKUP_STATUSES]
trend_runs = [run for run in completed_real_runs if run["has_stats"]][:limit]
latest_snapshot = host.snapshots.order_by("-started_at", "-discovered_at", "-id").first()
latest_snapshot_stats = _snapshot_summary(latest_snapshot) if latest_snapshot else {}
backup_data = _backup_data_by_kind(host)
literal_values = [_int_at(run, "rsync", "literal_data_bytes") for run in trend_runs]
literal_values = [value for value in literal_values if value is not None]
matched_values = [_int_at(run, "rsync", "matched_data_bytes") for run in trend_runs]
matched_values = [value for value in matched_values if value is not None]
max_literal = max(literal_values) if literal_values else 0
max_matched = max(matched_values) if matched_values else 0
return {
"runs": [_with_bar_percentages(run, max_literal=max_literal, max_matched=max_matched) for run in trend_runs],
"latest_run": completed_real_runs[0] if completed_real_runs else {},
"latest_good_run": _first_run_with_status(real_runs, {BackupRun.Status.SUCCESS}),
"latest_problem_run": _first_run_with_status(real_runs, {BackupRun.Status.WARNING, BackupRun.Status.FAILED}),
"latest_snapshot": latest_snapshot_stats,
"backup_data": backup_data,
"avg_literal_data_bytes": _average(literal_values),
"avg_daily_literal_data_bytes": _average_daily_literal(trend_runs),
"total_literal_data_bytes": sum(literal_values),
"total_matched_data_bytes": sum(matched_values),
}
def _run_summary(run: BackupRun) -> dict[str, Any]:
result = run.result if isinstance(run.result, dict) else {}
stats = result.get("stats") if isinstance(result.get("stats"), dict) else {}
return {
"id": run.id,
"host": run.host.host,
"run_type": run.run_type,
"started_at": run.started_at,
"ended_at": run.ended_at,
"snapshot": run.snapshot,
"snapshot_path": run.snapshot_path,
"status": run.status,
"reviewed_at": run.reviewed_at,
"has_stats": bool(stats),
"duration_seconds": _int_at(stats, "duration_seconds"),
"rsync": stats.get("rsync") if isinstance(stats.get("rsync"), dict) else {},
"storage": stats.get("storage") if isinstance(stats.get("storage"), dict) else {},
}
def _backup_data_by_kind(host: HostConfig) -> dict[str, Any]:
rows: dict[str, dict[str, int]] = {
SnapshotRecord.Kind.SCHEDULED: _empty_snapshot_data_row(),
SnapshotRecord.Kind.MANUAL: _empty_snapshot_data_row(),
SnapshotRecord.Kind.INCOMPLETE: _empty_snapshot_data_row(),
}
total = _empty_snapshot_data_row()
for snapshot in host.snapshots.all():
summary = _snapshot_summary(snapshot)
row = rows.setdefault(snapshot.kind, _empty_snapshot_data_row())
allocated = summary.get("allocated_size_bytes") or summary.get("apparent_size_bytes") or 0
apparent = summary.get("apparent_size_bytes") or 0
unique_apparent = summary.get("unique_apparent_size_bytes") or 0
row["count"] += 1
row["allocated_size_bytes"] += int(allocated)
row["apparent_size_bytes"] += int(apparent)
row["unique_apparent_size_bytes"] += int(unique_apparent)
total["count"] += 1
total["allocated_size_bytes"] += int(allocated)
total["apparent_size_bytes"] += int(apparent)
total["unique_apparent_size_bytes"] += int(unique_apparent)
return {
"scheduled": rows[SnapshotRecord.Kind.SCHEDULED],
"manual": rows[SnapshotRecord.Kind.MANUAL],
"incomplete": rows[SnapshotRecord.Kind.INCOMPLETE],
"total": total,
}
def _empty_snapshot_data_row() -> dict[str, int]:
return {
"count": 0,
"allocated_size_bytes": 0,
"apparent_size_bytes": 0,
"unique_apparent_size_bytes": 0,
}
def _sum_backup_data_by_kind(rows: Iterable[dict[str, dict[str, int]]]) -> dict[str, dict[str, int]]:
total_rows: dict[str, dict[str, int]] = {
"scheduled": _empty_snapshot_data_row(),
"manual": _empty_snapshot_data_row(),
"incomplete": _empty_snapshot_data_row(),
"total": _empty_snapshot_data_row(),
}
for row in rows:
for kind, values in row.items():
total_row = total_rows.setdefault(kind, _empty_snapshot_data_row())
total_row["count"] += values.get("count", 0)
total_row["allocated_size_bytes"] += values.get("allocated_size_bytes", 0)
total_row["apparent_size_bytes"] += values.get("apparent_size_bytes", 0)
total_row["unique_apparent_size_bytes"] += values.get("unique_apparent_size_bytes", 0)
return total_rows
def _snapshot_summary(snapshot: SnapshotRecord | None) -> dict[str, Any]:
if snapshot is None:
return {}
metadata = snapshot.metadata if isinstance(snapshot.metadata, dict) else {}
stats = metadata.get("stats") if isinstance(metadata.get("stats"), dict) else {}
storage = stats.get("storage") if isinstance(stats.get("storage"), dict) else {}
snapshot_storage = storage.get("snapshot") if isinstance(storage.get("snapshot"), dict) else {}
if snapshot.kind == SnapshotRecord.Kind.INCOMPLETE:
snapshot_storage = _snapshot_storage_from_filesystem(snapshot)
else:
has_recorded_size = (
_int_at(snapshot_storage, "allocated_size_bytes") is not None
or _int_at(snapshot_storage, "apparent_size_bytes") is not None
)
if not has_recorded_size:
snapshot_storage = _snapshot_storage_from_filesystem(snapshot)
apparent_size = _int_at(snapshot_storage, "apparent_size_bytes")
hardlinked_apparent = _int_at(snapshot_storage, "hardlinked_apparent_size_bytes") or 0
return {
"id": snapshot.id,
"dirname": snapshot.dirname,
"kind": snapshot.kind,
"status": snapshot.status,
"started_at": snapshot.started_at,
"apparent_size_bytes": apparent_size,
"allocated_size_bytes": _int_at(snapshot_storage, "allocated_size_bytes"),
"hardlinked_files": _int_at(snapshot_storage, "hardlinked_files"),
"hardlinked_apparent_size_bytes": hardlinked_apparent,
"unique_apparent_size_bytes": max((apparent_size or 0) - hardlinked_apparent, 0),
}
def _snapshot_storage_from_filesystem(snapshot: SnapshotRecord) -> dict[str, Any]:
if not snapshot.path:
return {}
snapshot_path = Path(snapshot.path)
data_path = snapshot_path / "data"
if snapshot_path.name == "data":
return tree_usage(snapshot_path)
if data_path.exists():
return tree_usage(data_path)
return tree_usage(snapshot_path)
def _is_real_run(run: BackupRun) -> bool:
result = run.result if isinstance(run.result, dict) else {}
if result.get("dry_run") is True:
return False
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
return requested.get("dry_run") is not True
def _first_run_with_status(runs: list[dict[str, Any]], statuses: set[str]) -> dict[str, Any]:
for run in runs:
if run["status"] in statuses and run.get("reviewed_at") is None:
return run
return {}
def _capacity_from_system(global_config: GlobalConfig | None) -> dict[str, Any]:
if global_config is None or not global_config.backup_root:
return {}
return filesystem_capacity(Path(global_config.backup_root))
def _latest_capacity_from_runs(runs: list[dict[str, Any]]) -> dict[str, Any]:
for run in runs:
capacity = _dict_at(run, "storage", "capacity")
if capacity:
return capacity
return {}
def _average(values: list[int]) -> int | None:
if not values:
return None
return int(sum(values) / len(values))
def _average_daily_literal(runs: list[dict[str, Any]]) -> int | None:
values = [_int_at(run, "rsync", "literal_data_bytes") for run in runs]
values = [value for value in values if value is not None]
if not values:
return None
timestamps = [run["started_at"] for run in runs if run.get("started_at") is not None]
if len(timestamps) < 2:
return _average(values)
oldest = min(timestamps)
newest = max(timestamps)
if timezone.is_naive(oldest):
oldest = timezone.make_aware(oldest)
if timezone.is_naive(newest):
newest = timezone.make_aware(newest)
span_days = max((newest - oldest).total_seconds() / 86400, 1)
return int(sum(values) / span_days)
def _with_bar_percentages(run: dict[str, Any], *, max_literal: int, max_matched: int) -> dict[str, Any]:
run = dict(run)
literal = _int_at(run, "rsync", "literal_data_bytes") or 0
matched = _int_at(run, "rsync", "matched_data_bytes") or 0
run["literal_percent"] = _percentage(literal, max_literal)
run["matched_percent"] = _percentage(matched, max_matched)
return run
def _percentage(value: int, maximum: int) -> int:
if maximum <= 0 or value <= 0:
return 0
return max(1, min(100, int(value / maximum * 100)))
def _dict_at(data: dict[str, Any], *keys: str) -> dict[str, Any]:
value: Any = data
for key in keys:
if not isinstance(value, dict):
return {}
value = value.get(key)
return value if isinstance(value, dict) else {}
def _int_at(data: dict[str, Any], *keys: str) -> int | None:
value: Any = data
for key in keys:
if not isinstance(value, dict):
return None
value = value.get(key)
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
return None
_COMPLETED_BACKUP_STATUSES = [BackupRun.Status.SUCCESS, BackupRun.Status.WARNING]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Changelog - pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Release notes</div>
<h1>Changelog</h1>
<div class="page-subtitle">Installed release notes rendered from the repository changelog.</div>
</div>
<section class="actions" aria-label="Changelog actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
</header>
<section class="panel">
<div class="stack spaced">
<div><strong>Installed version:</strong> {{ app_version }}</div>
<div class="muted">Changelog file: {{ changelog_path }}</div>
{% if missing %}
<div class="status warning">missing</div>
{% endif %}
</div>
<div class="stack">
{% for block in changelog_blocks %}
{% if block.kind == "heading" %}
{% if block.level == 1 %}
<h2>{{ block.text }}</h2>
{% else %}
<h3>{{ block.text }}</h3>
{% endif %}
{% elif block.kind == "list" %}
<ul>
{% for item in block.items %}
<li>{{ item }}</li>
{% endfor %}
</ul>
{% else %}
<p>{{ block.text }}</p>
{% endif %}
{% endfor %}
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,110 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}pobsync dashboard{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Control panel</div>
<h1>Dashboard</h1>
<div class="page-subtitle">Backup health, required action, storage pressure, and recent activity in one place.</div>
</div>
{% if can_manage_control_panel %}
<section class="actions" aria-label="Dashboard actions">
<a class="button-link" href="{% url 'create_host_config' %}">New host</a>
<a class="button-link secondary" href="{% url 'edit_global_config' %}">{% if global_config %}Edit global config{% else %}Create global config{% endif %}</a>
</section>
{% endif %}
</header>
{% if can_manage_control_panel %}
{% if not global_config or not counts.hosts %}
<section class="panel">
<h2>Setup</h2>
{% if not global_config %}
<p class="muted">No default global config exists yet. Create it first so discovery, backups, and retention know where pobsync stores snapshots.</p>
<div class="actions inline">
<a class="button-link" href="{% url 'edit_global_config' %}">Create global config</a>
</div>
{% elif not counts.hosts %}
<p class="muted">Global config is ready. Add the first host to make this dashboard useful.</p>
<div class="actions inline">
<a class="button-link" href="{% url 'create_host_config' %}">Add first host</a>
</div>
{% endif %}
</section>
{% endif %}
{% endif %}
<div
data-refresh-url="{% url 'dashboard_priority_live' %}"
data-refresh-interval="10000"
data-refresh-active="true"
aria-live="polite"
>
{% include "pobsync_backend/partials/dashboard_priority.html" %}
</div>
<section class="grid dashboard-summary-grid" aria-label="Summary">
<a class="metric metric-link" href="{% url 'hosts_list' %}"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></a>
<a class="metric metric-link" href="{% url 'schedules_list' %}"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></a>
<a class="metric metric-link" href="{% url 'snapshots_list' %}"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></a>
<a class="metric metric-link" href="{% url 'runs_list' %}"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></a>
<a class="metric metric-link {% if counts.warning_runs %}warning{% endif %}" href="{% url 'runs_list' %}?status=warning&amp;review=needed"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></a>
<a class="metric metric-link {% if counts.failed_runs %}failed{% endif %}" href="{% url 'runs_list' %}?status=failed&amp;review=needed"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></a>
</section>
<section class="panel dashboard-trends-panel">
<h2>Backup Trends</h2>
{% if stats_summary.runs_sampled %}
<div class="insight-grid" aria-label="Backup trends">
<div class="insight-item">
<div class="label">Runway</div>
<div class="value">
{% if stats_summary.estimated_days_until_full %}
{{ stats_summary.estimated_days_until_full }} days
{% elif stats_summary.estimated_runs_until_full %}
{{ stats_summary.estimated_runs_until_full }} runs
{% else %}
unknown
{% endif %}
</div>
<div class="muted">Estimated from average new data per day.</div>
</div>
<div class="insight-item">
<div class="label">New Data</div>
<div class="value">{{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}/day</div>
<div class="muted">{{ stats_summary.avg_literal_data_bytes|filesizeformat }} per backup on average.</div>
</div>
<div class="insight-item">
<div class="label">Link-Dest Savings</div>
<div class="value">
{% if stats_summary.link_dest_savings_percent is not None %}
{{ stats_summary.link_dest_savings_percent|floatformat:1 }}%
{% else %}
unknown
{% endif %}
</div>
<div class="muted">{{ stats_summary.total_matched_data_bytes|filesizeformat }} reused across sampled runs.</div>
</div>
<div class="insight-item">
<div class="label">Average Duration</div>
<div class="value">{{ stats_summary.avg_duration_seconds|default:"unknown" }}{% if stats_summary.avg_duration_seconds is not None %}s{% endif %}</div>
<div class="muted">Based on {{ stats_summary.runs_sampled }} completed backup run{{ stats_summary.runs_sampled|pluralize }} with stats.</div>
</div>
</div>
{% else %}
<p class="muted">No completed backup runs with stats yet. This section will show disk usage, growth estimates, and link-dest savings after the first real backup finishes.</p>
{% endif %}
</section>
<div
data-refresh-url="{% url 'dashboard_hosts_live' %}"
data-refresh-interval="15000"
data-refresh-active="true"
aria-live="polite"
>
{% include "pobsync_backend/partials/dashboard_hosts.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,74 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Global Config{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Configuration</div>
<h1>{% if global_config %}Global Config{% else %}Create Global Config{% endif %}</h1>
<div class="page-subtitle">Defaults used by hosts unless a host overrides them explicitly.</div>
</div>
<section class="actions" aria-label="Global config actions">
<a class="button-link" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
</header>
<section class="panel">
<h2>{% if global_config %}Edit Global Config{% else %}Create Global Config{% endif %}</h2>
<div class="stack spaced">
<div><strong>Backup root:</strong> {{ backup_root }}</div>
<div class="muted">This path is managed by the service environment and is saved with the config.</div>
</div>
<form method="post" class="form-grid">
{% csrf_token %}
{{ form.non_field_errors }}
{% for field in form %}
<div class="field">
{{ field.errors }}
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}<div class="helptext">{{ field.help_text }}</div>{% endif %}
</div>
{% endfor %}
<div class="form-actions">
<button type="submit">Save global config</button>
<a class="button-link secondary" href="{% url 'dashboard' %}">Cancel</a>
</div>
</form>
</section>
{% if config_checks %}
<section class="panel">
<h2>Config Check</h2>
<section class="grid" aria-label="Global config check summary">
<div class="metric"><div class="label">OK</div><div class="value">{{ config_check_summary.ok }}</div></div>
<div class="metric"><div class="label">Warnings</div><div class="value">{{ config_check_summary.warning }}</div></div>
<div class="metric"><div class="label">Failed</div><div class="value">{{ config_check_summary.failed }}</div></div>
<div class="metric"><div class="label">Skipped</div><div class="value">{{ config_check_summary.skipped }}</div></div>
</section>
<table>
<thead>
<tr>
<th>Status</th>
<th>Check</th>
<th>Message</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{% for check in config_checks %}
<tr>
<td><span class="status {{ check.status }}">{{ check.status }}</span></td>
<td>{{ check.name }}</td>
<td>{{ check.message }}</td>
<td class="muted">{{ check.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,584 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}{{ host.host }} | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Host</div>
<h1>{{ host.host }}</h1>
<div class="page-subtitle">{{ host.address }} · {{ host.enabled|yesno:"enabled,disabled" }}</div>
</div>
</header>
{% if retention_warning.has_warning %}
<section class="panel highlight warning">
<h2>Retention Warnings</h2>
<div class="stack">
{% if retention_warning.prune_exceeded %}
<div>
Scheduled pruning would delete {{ retention_warning.delete_count }} snapshot(s), above max delete
{{ retention_warning.max_delete }}. Scheduled pruning will refuse this plan until the limit or retention
selection is adjusted.
</div>
{% endif %}
{% if retention_warning.incomplete_count %}
<div>
{{ retention_warning.incomplete_count }} incomplete snapshot(s) exist. Retention does not delete incomplete
snapshots automatically; inspect them before cleanup.
</div>
{% if can_manage_control_panel %}
<form method="post" action="{% url 'resolve_host_incomplete_reviews' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary">Mark incomplete reviewed</button>
</form>
{% endif %}
{% endif %}
{% if retention_warning.error %}
<div>{{ retention_warning.error }}</div>
{% endif %}
</div>
</section>
{% endif %}
<section class="host-control-grid" aria-label="Host control workspace">
<article class="panel host-control-panel">
<h2>Host Status</h2>
<div class="host-control-primary">
<div>
{% if host.enabled %}
<span class="status ok">enabled</span>
{% else %}
<span class="status failed">disabled</span>
{% endif %}
<span class="muted">{{ host.address }}</span>
</div>
{% if active_run %}
<a class="status-summary {{ active_run.status }}" href="{% url 'run_detail' active_run.id %}">
<span class="status {{ active_run.status }}">{{ active_run.status }}</span>
<strong>Run {{ active_run.id }} is active.</strong>
</a>
{% elif counts.failed_runs %}
<a class="status-summary failed" href="{% url 'runs_list' %}?host={{ host.host }}&amp;status=failed&amp;review=needed">
<span class="status failed">failed</span>
<strong>{{ counts.failed_runs }} failed run{{ counts.failed_runs|pluralize }} need review.</strong>
</a>
{% elif retention_warning.has_warning %}
<span class="status-summary warning">
<span class="status warning">warning</span>
<strong>Retention needs attention.</strong>
</span>
{% else %}
<span class="status-summary success">
<span class="status ok">ok</span>
<strong>No active blockers for this host.</strong>
</span>
{% endif %}
</div>
<div class="host-control-meta">
<div><span class="label">Snapshots</span><strong>{{ counts.snapshots }}</strong></div>
<div><span class="label">Runs</span><strong>{{ counts.runs }}</strong></div>
<div><span class="label">Incomplete</span><strong>{{ counts.incomplete_snapshots }}</strong></div>
</div>
</article>
{% if can_manage_control_panel %}
<article class="panel host-control-panel">
<h2>Backup Control</h2>
<div class="operator-state">
{% if active_run %}
<span class="status {{ active_run.status }}">{{ active_run.status }}</span>
<a href="{% url 'run_detail' active_run.id %}">Run {{ active_run.id }}</a>
{% elif has_global_config and host.enabled %}
<span class="status {{ backup_gate.state }}">{{ backup_gate.state }}</span>
<span class="muted">{{ backup_gate.message }}</span>
{% elif not host.enabled %}
<span class="status failed">disabled</span>
{% elif not has_global_config %}
<span class="status failed">missing global config</span>
{% endif %}
</div>
<section class="actions inline" aria-label="Quick backup actions">
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
{% csrf_token %}
<input type="hidden" name="dry_run" value="on">
<input type="hidden" name="verbose_output" value="on">
<input type="hidden" name="prune_max_delete" value="10">
<button type="submit" class="secondary" {% if not can_queue_dry_run %}disabled{% endif %}>Queue dry-run</button>
</form>
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
{% csrf_token %}
<input type="hidden" name="verbose_output" value="on">
<input type="hidden" name="prune_max_delete" value="10">
<button type="submit" {% if not can_queue_real_backup %}disabled{% endif %}>Queue backup</button>
</form>
</section>
{% if active_run %}
<p class="muted">Wait for the active run to finish, or cancel it from the run detail page.</p>
{% elif not can_queue_dry_run or not can_queue_real_backup %}
{% if not has_global_config %}
<p class="muted">Create the default global config before queueing backups.</p>
{% elif not host.enabled %}
<p class="muted">Enable this host before queueing backups.</p>
{% elif backup_gate.real_blockers %}
<p class="muted">Real backups are blocked by failed preflight checks. Dry-runs may still be available when storage-only checks fail.</p>
{% endif %}
{% endif %}
</article>
{% endif %}
<article class="panel host-control-panel">
<h2>
Schedule
{% if can_manage_control_panel %}
<a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a>
{% endif %}
</h2>
{% if schedule %}
<div class="host-control-meta">
<div><span class="label">Schedule expression</span><strong>{{ schedule.cron_expr }}</strong></div>
<div><span class="label">Next run</span><strong>{% if next_run_at %}{{ next_run_at|date:"Y-m-d H:i T" }}{% else %}none{% endif %}</strong></div>
<div><span class="label">Timezone</span><strong>{{ scheduler_timezone }}</strong></div>
<div><span class="label">Prune</span><strong>{{ schedule.prune|yesno:"yes,no" }}</strong></div>
<div><span class="label">Last status</span><strong>{{ schedule.last_status|default:"none" }}</strong></div>
</div>
<p class="muted">Evaluated by the pobsync scheduler service.</p>
{% else %}
<p class="muted">No schedule configured.</p>
{% if can_manage_control_panel %}
<a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Add schedule</a>
{% endif %}
{% endif %}
</article>
<article class="panel host-control-panel">
<h2>Current Activity</h2>
{% if latest_runs %}
{% with run=latest_runs.0 %}
<a class="activity-row" href="{% url 'run_detail' run.id %}">
<span class="status {{ run.status }}">{{ run.status }}</span>
<span>
<strong>Run {{ run.id }}</strong>
<span class="muted">{{ run.run_type }} · {{ run.started_at|default:run.created_at }}</span>
</span>
</a>
{% endwith %}
{% else %}
<p class="muted">No backup runs recorded for this host.</p>
{% endif %}
{% if stats_summary.latest_run.duration_seconds is not None %}
<div class="host-control-meta">
<div><span class="label">Latest duration</span><strong>{{ stats_summary.latest_run.duration_seconds }}s</strong></div>
<div><span class="label">New data</span><strong>{{ stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}</strong></div>
</div>
{% endif %}
</article>
</section>
<section class="grid" aria-label="Host summary">
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
<div class="metric"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div>
<div class="metric"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
<div class="metric"><div class="label">Failed Runs</div><div class="value">{{ counts.failed_runs }}</div></div>
<div class="metric"><div class="label">Incomplete</div><div class="value">{{ counts.incomplete_snapshots }}</div></div>
</section>
<section class="panel">
<h2>Backup Data</h2>
<section class="grid" aria-label="Host backup data totals">
<div class="metric">
<div class="label">Scheduled</div>
<div class="value">{{ stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }}</div>
<div class="muted">unique {{ stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }}</div>
</div>
<div class="metric">
<div class="label">Manual</div>
<div class="value">{{ stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}</div>
<div class="muted">unique {{ stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }}</div>
</div>
<div class="metric">
<div class="label">Incomplete</div>
<div class="value">{{ stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}</div>
<div class="muted">measured from disk</div>
</div>
<div class="metric">
<div class="label">Total</div>
<div class="value">{{ stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}</div>
<div class="muted">unique {{ stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }}</div>
</div>
</section>
<p class="muted">
Main totals use allocated snapshot size. Unique values estimate non-hardlinked visible data; incomplete
snapshots are measured from disk because their metadata can be stale.
</p>
</section>
{% if stats_summary.runs %}
<section class="panel">
<h2>Backup Trends</h2>
<section class="grid" aria-label="Host backup trend summary">
<div class="metric"><div class="label">Avg New Data</div><div class="value">{{ stats_summary.avg_literal_data_bytes|filesizeformat }}</div></div>
<div class="metric"><div class="label">Avg Daily New</div><div class="value">{{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}</div></div>
<div class="metric"><div class="label">Total New Data</div><div class="value">{{ stats_summary.total_literal_data_bytes|filesizeformat }}</div></div>
<div class="metric"><div class="label">Matched Data</div><div class="value">{{ stats_summary.total_matched_data_bytes|filesizeformat }}</div></div>
<div class="metric"><div class="label">Latest Duration</div><div class="value">{{ stats_summary.latest_run.duration_seconds|default:"" }}{% if stats_summary.latest_run.duration_seconds is not None %}s{% endif %}</div></div>
</section>
<table>
<thead>
<tr>
<th>Run</th>
<th>Type</th>
<th>Started</th>
<th>Duration</th>
<th>Files</th>
<th>New Data</th>
<th>Matched</th>
<th>Trend</th>
<th>Snapshot</th>
</tr>
</thead>
<tbody>
{% for run in stats_summary.runs %}
<tr>
<td><a href="{% url 'run_detail' run.id %}">Run {{ run.id }}</a></td>
<td>{{ run.run_type }}</td>
<td>{{ run.started_at|default:"" }}</td>
<td>{{ run.duration_seconds|default:"" }}{% if run.duration_seconds is not None %}s{% endif %}</td>
<td>{{ run.rsync.files_total|default:"" }}</td>
<td>{{ run.rsync.literal_data_bytes|filesizeformat }}</td>
<td>{{ run.rsync.matched_data_bytes|filesizeformat }}</td>
<td>
<div class="trend-bars" aria-label="Run data trend">
<div class="trend-bar" title="New data"><span style="width: {{ run.literal_percent }}%"></span></div>
<div class="trend-bar matched" title="Matched data"><span style="width: {{ run.matched_percent }}%"></span></div>
<div class="trend-legend"><span>new</span><span>matched</span></div>
</div>
</td>
<td>{% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endif %}
{% if can_manage_control_panel %}
<section class="panel">
<h2>Host Check</h2>
<section class="grid" aria-label="Host check summary">
<div class="metric"><div class="label">OK</div><div class="value">{{ host_check_summary.ok }}</div></div>
<div class="metric"><div class="label">Warnings</div><div class="value">{{ host_check_summary.warning }}</div></div>
<div class="metric"><div class="label">Failed</div><div class="value">{{ host_check_summary.failed }}</div></div>
<div class="metric"><div class="label">Skipped</div><div class="value">{{ host_check_summary.skipped }}</div></div>
</section>
<div class="record-list">
{% for check in host_checks %}
<article class="record-card">
<div class="record-card-header">
<div class="record-title">
<strong>{{ check.name }}</strong>
<span class="muted">{{ check.message }}</span>
</div>
<span class="status {{ check.status }}">{{ check.status }}</span>
</div>
{% if check.detail %}
<div class="record-fact">
<span class="label">Detail</span>
<span class="muted">{{ check.detail }}</span>
</div>
{% endif %}
</article>
{% endfor %}
</div>
</section>
{% endif %}
<div class="panel-grid">
<section class="panel">
<h2>Configuration</h2>
<div class="host-control-meta">
<div><span class="label">Address</span><strong>{{ host.address }}</strong></div>
{% if can_manage_control_panel %}
<div><span class="label">SSH key</span><strong>{{ host.ssh_credential|default:"global default" }}</strong></div>
<div><span class="label">SSH</span><strong>{{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</strong></div>
{% endif %}
<div><span class="label">Backup source</span><strong>{{ host.source_root|default:"global default" }}</strong></div>
<div><span class="label">Retention</span><strong>d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</strong></div>
</div>
{% if can_manage_control_panel %}
<div class="actions inline">
<a class="button-link secondary compact" href="{% url 'edit_host_config' host.host %}">Edit config</a>
<a class="button-link secondary compact" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
</div>
{% endif %}
</section>
{% if can_manage_control_panel %}
<section class="panel">
<h2>Connection Preflight &amp; SSH</h2>
{% if last_preflight %}
<div class="host-control-meta">
<div>
<span class="label">Preflight</span>
<strong>
<span class="status {% if last_preflight.ok %}ok{% else %}failed{% endif %}">
{% if last_preflight.ok %}ok{% else %}failed{% endif %}
</span>
</strong>
</div>
<div><span class="label">Target</span><strong>{{ last_preflight.target }}</strong></div>
<div><span class="label">Backup source</span><strong>{{ last_preflight.source_root }}</strong></div>
<div><span class="label">Remote rsync</span><strong>{{ last_preflight.rsync_binary }}</strong></div>
</div>
{% else %}
<p class="muted">No connection preflight recorded yet.</p>
{% endif %}
<div class="actions inline">
<form method="post" action="{% url 'run_host_preflight' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary compact">Run connection preflight</button>
</form>
<form method="post" action="{% url 'scan_host_known_key' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary compact">Scan SSH host key</button>
</form>
</div>
{% if last_preflight.checks %}
<div class="activity-list">
{% for check in last_preflight.checks %}
<div class="activity-row">
<span class="status {% if check.ok %}ok{% else %}failed{% endif %}">
{% if check.ok %}ok{% else %}failed{% endif %}
</span>
<span>
<strong>{{ check.name }}</strong>
<span class="muted">{{ check.message }}{% if check.detail %} · {{ check.detail }}{% endif %}</span>
</span>
</div>
{% endfor %}
</div>
{% endif %}
</section>
{% endif %}
<section class="panel">
<h2>Snapshot Storage</h2>
<div class="host-control-meta">
<div><span class="label">Backup root</span><strong>{{ discovery.backup_root|default:"" }}</strong></div>
<div><span class="label">Host root</span><strong>{{ discovery.host_root|default:"" }}</strong></div>
<div><span class="label">Status</span><strong>{{ discovery.message }}</strong></div>
{% if discovery.kind_counts %}
<div>
<span class="label">On disk</span>
<strong>
scheduled {{ discovery.kind_counts.scheduled|default:0 }},
manual {{ discovery.kind_counts.manual|default:0 }},
incomplete {{ discovery.kind_counts.incomplete|default:0 }}
</strong>
</div>
{% endif %}
</div>
{% if can_manage_control_panel %}
<div class="actions inline">
<form method="post" action="{% url 'discover_host_snapshots' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary compact">Discover snapshots</button>
</form>
<form method="post" action="{% url 'prepare_host_directories' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary compact">Prepare directories</button>
</form>
</div>
{% endif %}
</section>
</div>
{% if effective_config %}
<section class="panel">
<h2>Effective Config</h2>
<p class="muted">Runtime settings after global defaults and host overrides are combined.</p>
<div class="record-list">
<article class="record-card">
<div class="record-card-header">
<div class="record-title">
<strong>Backup target</strong>
<span class="muted">Source and destination used by rsync.</span>
</div>
</div>
<div class="record-facts">
<div class="record-fact"><span class="label">Backup source:</span><strong>{{ effective_config.source_root }}</strong></div>
<div class="record-fact"><span class="label">Destination subdir:</span><strong>{{ effective_config.destination_subdir|default:"none" }}</strong></div>
</div>
</article>
<article class="record-card">
<div class="record-card-header">
<div class="record-title">
<strong>Connection</strong>
<span class="muted">SSH and rsync execution settings.</span>
</div>
</div>
<div class="record-facts">
<div class="record-fact"><span class="label">SSH:</span><strong>{{ effective_config.ssh.user }}@{{ host.address }}:{{ effective_config.ssh.port }}</strong></div>
<div class="record-fact"><span class="label">SSH key:</span><strong>{{ effective_config.ssh.credential|default:"none selected" }}</strong></div>
<div class="record-fact"><span class="label">SSH options:</span><span>{{ effective_config.ssh.options|join:" " }}</span></div>
<div class="record-fact"><span class="label">Rsync binary:</span><strong>{{ effective_config.rsync.binary }}</strong></div>
<div class="record-fact"><span class="label">Rsync args:</span><span>{{ effective_config.rsync.args|join:" " }}</span></div>
<div class="record-fact"><span class="label">Timeout:</span><strong>{{ effective_config.rsync.timeout_seconds }}s</strong></div>
<div class="record-fact">
<span class="label">Bandwidth limit:</span>
<strong>{% if effective_config.rsync.bwlimit_kbps %}{{ effective_config.rsync.bwlimit_kbps }} KB/s{% else %}unlimited{% endif %}</strong>
</div>
</div>
</article>
<article class="record-card">
<div class="record-card-header">
<div class="record-title">
<strong>Selection &amp; retention</strong>
<span class="muted">Include/exclude rules and retention counts.</span>
</div>
</div>
<div class="record-facts">
<div class="record-fact">
<span class="label">Retention:</span>
<strong>
d{{ effective_config.retention.daily }}
w{{ effective_config.retention.weekly }}
m{{ effective_config.retention.monthly }}
y{{ effective_config.retention.yearly }}
</strong>
</div>
<div class="record-fact"><span class="label">Includes:</span><strong>{{ effective_config.includes|length }}</strong></div>
<div class="record-fact"><span class="label">Excludes:</span><strong>{{ effective_config.excludes|length }}</strong></div>
</div>
<div class="two-col">
<div class="stack">
{% if effective_config.includes %}
<pre>{{ effective_config.includes|join:"&#10;" }}</pre>
{% else %}
<div class="muted">No include rules configured.</div>
{% endif %}
</div>
<div class="stack">
{% if effective_config.excludes %}
<pre>{{ effective_config.excludes|join:"&#10;" }}</pre>
{% else %}
<div class="muted">No exclude rules configured.</div>
{% endif %}
</div>
</div>
</article>
</div>
</section>
{% endif %}
{% if can_manage_control_panel %}
<section class="panel">
<h2>Backup Options</h2>
<p class="muted">Use this when the quick actions above need a custom label, include/exclude override, or prune limit.</p>
<form method="post" action="{% url 'queue_manual_backup' host.host %}" class="form-grid">
{% csrf_token %}
{{ manual_backup_form.non_field_errors }}
{% for field in manual_backup_form %}
<div class="field">
{{ field.errors }}
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}<div class="helptext">{{ field.help_text }}</div>{% endif %}
</div>
{% endfor %}
<div class="form-actions">
<button type="submit" {% if not can_queue_dry_run and not can_queue_real_backup %}disabled{% endif %}>Queue with options</button>
</div>
</form>
</section>
{% endif %}
<section class="panel">
<h2>Latest Runs <a class="button-link secondary compact" href="{% url 'runs_list' %}?host={{ host.host }}">View all</a></h2>
<div class="record-list">
{% for run in latest_runs %}
<article class="record-card">
<div class="record-card-header">
<div class="record-title">
<a href="{% url 'run_detail' run.id %}">Run {{ run.id }}</a>
<span class="muted">{{ run.run_type }}{% if run.result.duration_seconds %} · {{ run.result.duration_seconds }}s{% endif %}</span>
</div>
<span class="status {{ run.status }}">{{ run.status }}</span>
</div>
<div class="record-facts">
<div class="record-fact">
<span class="label">Started</span>
<strong>{{ run.started_at|default:run.created_at }}</strong>
</div>
<div class="record-fact">
<span class="label">Ended</span>
<strong>{{ run.ended_at|default:"running or queued" }}</strong>
</div>
<div class="record-fact">
<span class="label">Snapshot</span>
{% if run.snapshot %}
<strong><a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a></strong>
{% elif run.snapshot_path %}
<span class="muted">{{ run.snapshot_path }}</span>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
<div class="record-fact">
<span class="label">Base</span>
<span class="muted">{{ run.base_path|default:"none" }}</span>
</div>
</div>
</article>
{% empty %}
<p class="muted">No backup runs recorded for this host.</p>
{% endfor %}
</div>
</section>
<section class="panel">
<h2>Snapshots <a class="button-link secondary compact" href="{% url 'snapshots_list' %}?host={{ host.host }}">View all</a></h2>
<div class="record-list">
{% for snapshot in snapshots %}
<article class="record-card">
<div class="record-card-header">
<div class="record-title">
<a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a>
<span class="muted">{{ snapshot.kind }}</span>
</div>
<span class="status {{ snapshot.status }}">{{ snapshot.status }}</span>
</div>
<div class="record-facts">
<div class="record-fact">
<span class="label">Started</span>
<strong>{{ snapshot.started_at|default:"unknown" }}</strong>
</div>
<div class="record-fact">
<span class="label">Ended</span>
<strong>{{ snapshot.ended_at|default:"unknown" }}</strong>
</div>
<div class="record-fact">
<span class="label">Base</span>
{% if snapshot.base %}
<strong><a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a></strong>
{% elif snapshot.base_dirname %}
<span class="muted">{{ snapshot.base_dirname }}</span>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
<div class="record-fact">
<span class="label">Path</span>
<span class="muted">{{ snapshot.path|default:"not recorded" }}</span>
</div>
</div>
</article>
{% empty %}
<p class="muted">No snapshots discovered for this host.</p>
{% endfor %}
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}{% if host %}Config | {{ host.host }}{% else %}New Host{% endif %}{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Configuration</div>
<h1>{% if host %}Config: {{ host.host }}{% else %}New Host{% endif %}</h1>
<div class="page-subtitle">Host-specific backup, retention, SSH, include, and exclude settings.</div>
</div>
<section class="actions" aria-label="Config actions">
{% if host %}
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
{% else %}
<a class="button-link" href="{% url 'dashboard' %}">Back to dashboard</a>
{% endif %}
</section>
</header>
<section class="panel">
<h2>{% if host %}Edit Host Config{% else %}Create Host Config{% endif %}</h2>
<form method="post" class="form-grid">
{% csrf_token %}
{{ form.non_field_errors }}
{% for field in form %}
<div class="field">
{{ field.errors }}
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}<div class="helptext">{{ field.help_text }}</div>{% endif %}
</div>
{% endfor %}
<div class="form-actions">
<button type="submit">{% if host %}Save config{% else %}Create host{% endif %}</button>
{% if host %}
<a class="button-link secondary" href="{% url 'host_detail' host.host %}">Cancel</a>
{% else %}
<a class="button-link secondary" href="{% url 'dashboard' %}">Cancel</a>
{% endif %}
</div>
</form>
</section>
{% if config_checks %}
<section class="panel">
<h2>Effective Config Check</h2>
<section class="grid" aria-label="Host config check summary">
<div class="metric"><div class="label">OK</div><div class="value">{{ config_check_summary.ok }}</div></div>
<div class="metric"><div class="label">Warnings</div><div class="value">{{ config_check_summary.warning }}</div></div>
<div class="metric"><div class="label">Failed</div><div class="value">{{ config_check_summary.failed }}</div></div>
<div class="metric"><div class="label">Skipped</div><div class="value">{{ config_check_summary.skipped }}</div></div>
</section>
<table>
<thead>
<tr>
<th>Status</th>
<th>Check</th>
<th>Message</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{% for check in config_checks %}
<tr>
<td><span class="status {{ check.status }}">{{ check.status }}</span></td>
<td>{{ check.name }}</td>
<td>{{ check.message }}</td>
<td class="muted">{{ check.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Hosts | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Inventory</div>
<h1>Hosts</h1>
<div class="page-subtitle">Configured backup targets, schedules, retention state, and host-level controls.</div>
</div>
{% if can_manage_control_panel %}
<section class="actions" aria-label="Host actions">
<a class="button-link" href="{% url 'create_host_config' %}">New host</a>
</section>
{% endif %}
</header>
<section class="grid dashboard-summary-grid" aria-label="Host summary">
<a class="metric metric-link" href="{% url 'hosts_list' %}"><div class="label">Showing</div><div class="value">{{ counts.hosts }}</div></a>
<a class="metric metric-link" href="{% url 'hosts_list' %}?enabled=yes"><div class="label">Enabled</div><div class="value">{{ counts.enabled_hosts }}</div></a>
<a class="metric metric-link" href="{% url 'hosts_list' %}?enabled=no"><div class="label">Disabled</div><div class="value">{{ counts.disabled_hosts }}</div></a>
<a class="metric metric-link" href="{% url 'dashboard' %}"><div class="label">Total</div><div class="value">{{ total_count }}</div></a>
</section>
<section class="panel">
<h2>Filters</h2>
<form class="filter-form" method="get">
<div class="field">
<label for="enabled">Host state</label>
<select id="enabled" name="enabled">
<option value="" {% if selected_enabled == "" %}selected{% endif %}>All hosts</option>
<option value="yes" {% if selected_enabled == "yes" %}selected{% endif %}>Enabled only</option>
<option value="no" {% if selected_enabled == "no" %}selected{% endif %}>Disabled only</option>
</select>
</div>
<div class="form-actions">
<button type="submit">Apply filters</button>
<a class="button-link secondary" href="{% url 'hosts_list' %}">Reset</a>
</div>
</form>
</section>
{% include "pobsync_backend/partials/dashboard_hosts.html" %}
{% endblock %}

View File

@@ -0,0 +1,74 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Logs | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Operations</div>
<h1>Logs</h1>
<div class="page-subtitle">Filter pobsync service logs by unit, priority, host, run, or message content.</div>
</div>
<section class="actions" aria-label="Log actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
</header>
<section class="panel">
<h2>Filter</h2>
<form method="get" class="filter-form">
<div class="field">
<label for="unit">Unit</label>
<select id="unit" name="unit">
<option value="">All pobsync units</option>
{% for unit in units %}
<option value="{{ unit }}" {% if selected_unit == unit %}selected{% endif %}>{{ unit }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="priority">Priority</label>
<select id="priority" name="priority">
{% for value, label in priorities.items %}
<option value="{{ value }}" {% if selected_priority == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="window">Time window</label>
<select id="window" name="window">
{% for value, label in time_windows.items %}
<option value="{{ value }}" {% if selected_window == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="host">Host contains</label>
<input id="host" name="host" value="{{ host_filter }}" placeholder="web-01.example.test">
</div>
<div class="field">
<label for="run">Run</label>
<input id="run" name="run" value="{{ run_filter }}" inputmode="numeric" placeholder="12">
</div>
<div class="field">
<label for="q">Message contains</label>
<input id="q" name="q" value="{{ query }}">
</div>
<div class="form-actions">
<button type="submit">Filter logs</button>
<a class="button-link secondary" href="{% url 'logs' %}">Clear</a>
</div>
</form>
</section>
<section class="panel">
<h2>Messages</h2>
{% if error %}
<p class="status failed">{{ error }}</p>
{% else %}
<pre>{% for line in lines %}{{ line }}
{% empty %}No log messages matched the current filter.
{% endfor %}</pre>
{% endif %}
</section>
{% endblock %}

View File

@@ -0,0 +1,38 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}{{ title }} | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Reports</div>
<h1>{{ title }}</h1>
<div class="page-subtitle">Choose which completed backup statuses should trigger an email or webhook report.</div>
</div>
<section class="actions" aria-label="Notification target form actions">
<a class="button-link" href="{% url 'notification_targets' %}">Back to notifications</a>
</section>
</header>
<section class="panel">
<h2>{% if target %}Edit Target{% else %}Create Target{% endif %}</h2>
<form method="post" class="form-grid">
{% csrf_token %}
{{ form.non_field_errors }}
{% for field in form %}
<div class="field">
{{ field.errors }}
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}<div class="helptext">{{ field.help_text }}</div>{% endif %}
</div>
{% endfor %}
<div class="form-actions">
<button type="submit">{{ submit_label }}</button>
<a class="button-link secondary" href="{% url 'notification_targets' %}">Cancel</a>
</div>
</form>
</section>
{% endblock %}

View File

@@ -0,0 +1,91 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Notifications | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Reports</div>
<h1>Notifications</h1>
<div class="page-subtitle">Send email or webhook reports when backup runs finish.</div>
</div>
<section class="actions" aria-label="Notification actions">
<a class="button-link" href="{% url 'create_notification_target' %}">New target</a>
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
</header>
<section class="panel">
<h2>Targets</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>Channel</th>
<th>Status</th>
<th>Events</th>
<th>Destination</th>
<th>Last delivery</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for target in targets %}
<tr>
<td><a href="{% url 'edit_notification_target' target.id %}">{{ target.name }}</a></td>
<td>{{ target.get_channel_display }}</td>
<td><span class="status {% if target.enabled %}ok{% else %}skipped{% endif %}">{{ target.enabled|yesno:"enabled,disabled" }}</span></td>
<td>{{ target.statuses|join:", " }}</td>
<td>
{% if target.channel == "email" %}
{{ target.email_to|linebreaksbr }}
{% else %}
<code>{{ target.webhook_url|truncatechars:70 }}</code>
{% endif %}
</td>
<td>
{% if target.last_status %}
<span class="status {{ target.last_status }}">{{ target.last_status }}</span>
{% if target.last_error %}<div class="muted">{{ target.last_error|truncatechars:90 }}</div>{% endif %}
{% if target.last_sent_at %}<div class="muted">{{ target.last_sent_at }}</div>{% endif %}
{% else %}
<span class="muted">none</span>
{% endif %}
</td>
<td><a class="button-link secondary" href="{% url 'edit_notification_target' target.id %}">Edit</a></td>
</tr>
{% empty %}
<tr><td colspan="7" class="muted">No notification targets configured yet.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="panel">
<h2>Recent Deliveries</h2>
<table>
<thead>
<tr>
<th>Target</th>
<th>Run</th>
<th>Status</th>
<th>Created</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{% for delivery in deliveries %}
<tr>
<td>{{ delivery.target.name }}</td>
<td><a href="{% url 'run_detail' delivery.run.id %}">Run {{ delivery.run.id }}</a> {{ delivery.run.host.host }}</td>
<td><span class="status {{ delivery.status }}">{{ delivery.status }}</span></td>
<td>{{ delivery.created_at }}</td>
<td class="muted">{{ delivery.error|default:"" }}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="muted">No notification deliveries recorded yet.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View File

@@ -0,0 +1,178 @@
<section class="panel dashboard-hosts-panel" id="hosts">
<h2>Hosts</h2>
<div class="host-list">
{% for host in hosts %}
<article class="host-card">
<div class="host-card-header">
<div class="host-card-title">
<a href="{% url 'host_detail' host.host %}">{{ host.host }}</a>
<span class="muted">{{ host.address }}</span>
</div>
<div class="host-card-status">
<span class="status {% if host.enabled %}ok{% else %}skipped{% endif %}">{{ host.enabled|yesno:"enabled,disabled" }}</span>
{% if host.queued_run_count %}
<span class="status queued">queued {{ host.queued_run_count }}</span>
{% endif %}
{% if host.running_run_count %}
<span class="status running">running {{ host.running_run_count }}</span>
{% endif %}
{% if host.warning_run_count %}
<span class="status warning">warning {{ host.warning_run_count }}</span>
{% endif %}
{% if host.failed_run_count %}
<span class="status failed">failed {{ host.failed_run_count }}</span>
{% endif %}
{% if show_host_controls %}
{% if host.schedule %}
<span class="status {% if host.schedule.enabled %}ok{% else %}skipped{% endif %}">schedule {{ host.schedule.enabled|yesno:"on,paused" }}</span>
<span class="status {% if host.schedule.prune %}ok{% else %}skipped{% endif %}">retention {{ host.schedule.prune|yesno:"on,paused" }}</span>
{% else %}
<span class="status skipped">no schedule</span>
{% endif %}
{% endif %}
</div>
</div>
<div class="host-card-layout">
<div class="host-card-section">
<div class="host-card-section-title">Backup activity</div>
<div class="host-card-timeline">
<div class="host-card-item">
<div class="label">Latest Snapshot</div>
<div class="value">
{% if host.latest_snapshot %}
<a href="{% url 'snapshot_detail' host.latest_snapshot.id %}">{{ host.latest_snapshot.dirname }}</a>
<div class="muted">{{ host.latest_snapshot.kind }} {{ host.latest_snapshot.status }}</div>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
</div>
<div class="host-card-item">
<div class="label">Last Good Backup</div>
<div class="value">
{% if host.stats_summary.latest_good_run.id %}
<a href="{% url 'run_detail' host.stats_summary.latest_good_run.id %}">Run {{ host.stats_summary.latest_good_run.id }}</a>
<div class="muted">{{ host.stats_summary.latest_good_run.run_type }} {{ host.stats_summary.latest_good_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_good_run.duration_seconds is not None %}s{% endif %}</div>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
</div>
<div class="host-card-item">
<div class="label">Latest Issue</div>
<div class="value">
{% if host.stats_summary.latest_problem_run.id %}
<a href="{% url 'run_detail' host.stats_summary.latest_problem_run.id %}">Run {{ host.stats_summary.latest_problem_run.id }}</a>
<div><span class="status {{ host.stats_summary.latest_problem_run.status }}">{{ host.stats_summary.latest_problem_run.status }}</span></div>
<div class="muted">{{ host.stats_summary.latest_problem_run.run_type }} {{ host.stats_summary.latest_problem_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_problem_run.duration_seconds is not None %}s{% endif %}</div>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
</div>
<div class="host-card-item">
<div class="label">Next Run</div>
<div class="value">
{% if host.next_run_at %}
{{ host.next_run_at|date:"Y-m-d H:i T" }}
<div class="muted">{{ scheduler_timezone }}</div>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
</div>
</div>
</div>
<div class="host-card-section">
<div class="host-card-section-title">Snapshot health</div>
<div class="host-card-stats">
<div class="host-card-stat">
<div class="label">Snapshots</div>
<div class="value">{{ host.snapshot_count }}</div>
</div>
<div class="host-card-stat">
<div class="label">Runs</div>
<div class="value">{{ host.run_count }}</div>
</div>
<div class="host-card-stat">
<div class="label">New Data</div>
<div class="value">{{ host.stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}</div>
</div>
<div class="host-card-stat">
<div class="label">Retention</div>
<div class="value">d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</div>
</div>
<div class="host-card-stat">
<div class="label">Scheduled data</div>
<div class="value">{{ host.stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }}</div>
<div class="muted">unique {{ host.stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }}</div>
</div>
<div class="host-card-stat">
<div class="label">Manual data</div>
<div class="value">{{ host.stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}</div>
<div class="muted">unique {{ host.stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }}</div>
</div>
<div class="host-card-stat">
<div class="label">Incomplete data</div>
<div class="value">{{ host.stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}</div>
<div class="muted">measured from disk</div>
</div>
<div class="host-card-stat">
<div class="label">Total data</div>
<div class="value">{{ host.stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}</div>
<div class="muted">unique {{ host.stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }}</div>
</div>
</div>
</div>
</div>
{% if host.retention_warning.has_warning %}
<div class="host-card-warning">
<span class="status warning">retention</span>
{% if host.retention_warning.prune_exceeded %}
Scheduled prune would delete {{ host.retention_warning.delete_count }} snapshot(s), above max {{ host.retention_warning.max_delete }}.
{% endif %}
{% if host.retention_warning.incomplete_count %}
{{ host.retention_warning.incomplete_count }} incomplete snapshot(s) need review.
<form method="post" action="{% url 'resolve_host_incomplete_reviews' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary">Mark reviewed</button>
</form>
{% endif %}
{% if host.retention_warning.error %}
{{ host.retention_warning.error }}
{% endif %}
</div>
{% endif %}
{% if show_host_controls %}
<div class="host-card-actions">
<a class="button-link compact secondary" href="{% url 'host_detail' host.host %}">Open</a>
<a class="button-link compact secondary" href="{% url 'edit_host_config' host.host %}">Edit config</a>
<a class="button-link compact secondary" href="{% url 'edit_host_schedule' host.host %}">{% if host.schedule %}Edit schedule{% else %}Create schedule{% endif %}</a>
<form class="inline-form" method="post" action="{% url 'update_host_state' host.host %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<input type="hidden" name="action" value="{% if host.enabled %}disable_host{% else %}enable_host{% endif %}">
<button class="compact {% if host.enabled %}secondary{% endif %}" type="submit">{{ host.enabled|yesno:"Disable host,Enable host" }}</button>
</form>
{% if host.schedule %}
<form class="inline-form" method="post" action="{% url 'update_host_state' host.host %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<input type="hidden" name="action" value="{% if host.schedule.enabled %}disable_schedule{% else %}enable_schedule{% endif %}">
<button class="compact secondary" type="submit">{{ host.schedule.enabled|yesno:"Pause schedule,Resume schedule" }}</button>
</form>
<form class="inline-form" method="post" action="{% url 'update_host_state' host.host %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<input type="hidden" name="action" value="{% if host.schedule.prune %}disable_prune{% else %}enable_prune{% endif %}">
<button class="compact secondary" type="submit">{{ host.schedule.prune|yesno:"Pause retention,Resume retention" }}</button>
</form>
{% endif %}
</div>
{% endif %}
</article>
{% empty %}
<p class="muted">No hosts configured yet.</p>
{% endfor %}
</div>
</section>

Some files were not shown because too many files have changed in this diff Show More