mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-02 18:17:11 +09:00
235 lines
9.6 KiB
Python
235 lines
9.6 KiB
Python
"""The Storage Hub on the panel side.
|
|
|
|
ServerKit Cloud holds one set of object-storage credentials and hands each
|
|
server its own prefix in the bucket. This module is the whole panel half of
|
|
that: four command handlers, and the snapshot the panel sends back on the
|
|
`storage` stream.
|
|
|
|
What it deliberately does not do is re-implement anything. The destination
|
|
lands in `storage.json` through `StorageProviderService.save_config`, which
|
|
every upload, list and verify path in the panel already reads — including
|
|
`path_prefix`, which those paths have always honoured. Cloud is therefore
|
|
just another way of filling in the form on Backups > Storage, and a backup
|
|
run does not know or care which one was used.
|
|
|
|
While a destination is managed by Cloud, `managed_by_cloud` is set in
|
|
storage.json and the local form is read-only: two places writing the same
|
|
config would race, and the operator should be told where it is coming from
|
|
rather than watching their edit disappear.
|
|
"""
|
|
import logging
|
|
|
|
from app.services.connect_commands import handler
|
|
from app.services.connect_format import iso_datetime as _iso
|
|
from app.services.storage_provider_service import StorageProviderService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ServerKit Cloud sends an S3 shape whatever the provider is (B2 through its
|
|
# S3-compatible endpoint, R2, Wasabi and the rest), so the panel keeps one
|
|
# client and writes one provider block.
|
|
PANEL_PROVIDER = 's3'
|
|
|
|
|
|
# ==================== commands ====================
|
|
|
|
|
|
@handler('storage.assign')
|
|
def assign(args: dict, app=None) -> dict:
|
|
"""Write the destination Cloud sent, then prove it works before saying so.
|
|
|
|
The reply is what flips the assignment to `ok` in Cloud, so it must be the
|
|
result of a real `test_connection` and not the fact that a file was
|
|
written.
|
|
"""
|
|
if args.get('error') == 'connection_gone':
|
|
return {'ok': False, 'summary': 'ServerKit Cloud no longer has that storage connection, '
|
|
'so nothing was changed here.'}
|
|
missing = [k for k in ('bucket', 'key_id', 'secret', 'prefix') if not args.get(k)]
|
|
if missing:
|
|
return {'ok': False, 'summary': f'The destination was incomplete: no {", ".join(missing)}.'}
|
|
|
|
previous = StorageProviderService.get_config()
|
|
# Merge, never replace: auto_upload, keep_local_copy and everything else
|
|
# the operator set on this panel is theirs and stays.
|
|
config = dict(previous)
|
|
config.update({
|
|
'provider': PANEL_PROVIDER,
|
|
'managed_by_cloud': True,
|
|
'managed_prefix': args['prefix'],
|
|
's3': {
|
|
'bucket': args['bucket'],
|
|
'region': args.get('region') or 'auto',
|
|
'endpoint_url': args.get('endpoint') or '',
|
|
'access_key': args['key_id'],
|
|
'secret_key': args['secret'],
|
|
'path_prefix': args['prefix'].rstrip('/'),
|
|
},
|
|
})
|
|
|
|
test = StorageProviderService.test_connection(config)
|
|
if not test.get('success'):
|
|
# Nothing is saved: a destination the panel cannot reach would stop
|
|
# the next offsite copy without anybody being told why.
|
|
return {'ok': False,
|
|
'summary': test.get('error') or 'The panel could not reach that destination.'}
|
|
|
|
StorageProviderService.save_config(config)
|
|
logger.info('Connect storage: destination set by ServerKit Cloud (%s/%s)',
|
|
args['bucket'], args['prefix'])
|
|
return {'ok': True,
|
|
'summary': f'Writing offsite backups to {args["bucket"]}/{args["prefix"]}',
|
|
'output': _previous_note(previous)}
|
|
|
|
|
|
def _previous_note(previous: dict) -> str | None:
|
|
"""What the destination used to be, for the command's stored output. The
|
|
operator can see what Cloud replaced without us keeping a second copy of
|
|
the old credentials anywhere."""
|
|
if not previous or previous.get('provider') in (None, 'local'):
|
|
return 'Previous destination: local only.'
|
|
block = previous.get(previous.get('provider'), {}) or {}
|
|
bucket = block.get('bucket')
|
|
return f'Previous destination: {previous.get("provider")}:{bucket or "unnamed bucket"}.'
|
|
|
|
|
|
@handler('storage.unassign')
|
|
def unassign(args: dict, app=None) -> dict:
|
|
"""Hand the destination back to this panel.
|
|
|
|
Backups keep running; the offsite copy stops. Nothing in the bucket is
|
|
touched — deleting a customer's objects is not something a command from
|
|
Cloud is allowed to do.
|
|
"""
|
|
config = StorageProviderService.get_config()
|
|
if not config.get('managed_by_cloud'):
|
|
return {'ok': True, 'summary': 'This panel\'s storage was not managed by Cloud; '
|
|
'nothing was changed.'}
|
|
# The credential came from Cloud and the operator never typed it, so it
|
|
# goes with the assignment. Their own settings stay.
|
|
config = {k: v for k, v in config.items() if k != PANEL_PROVIDER}
|
|
config.update({'provider': 'local', 'managed_by_cloud': False, 'managed_prefix': None})
|
|
StorageProviderService.save_config(config)
|
|
logger.info('Connect storage: destination handed back to this panel')
|
|
return {'ok': True, 'summary': 'Backups are local-only again. Nothing in the bucket was '
|
|
'deleted; set a destination here to start copying offsite.'}
|
|
|
|
|
|
@handler('storage.test')
|
|
def test(args: dict, app=None) -> dict:
|
|
result = StorageProviderService.test_connection()
|
|
if result.get('success'):
|
|
return {'ok': True, 'summary': result.get('message') or 'Destination reachable.'}
|
|
return {'ok': False, 'summary': result.get('error') or 'The destination could not be reached.'}
|
|
|
|
|
|
@handler('storage.report')
|
|
def report(args: dict, app=None) -> dict:
|
|
"""Send a snapshot now. The frame itself goes out through the relay client;
|
|
here we only prove we can build one."""
|
|
snapshot = build_status(app)
|
|
if snapshot is None:
|
|
return {'ok': False, 'summary': 'This panel has no storage status to report yet.'}
|
|
client = _relay_client()
|
|
if client is None or not client.publish_storage(snapshot):
|
|
return {'ok': False, 'summary': 'The panel is not connected to the relay right now, '
|
|
'so the snapshot could not be sent. It goes out on the '
|
|
'next connection.'}
|
|
return {'ok': True, 'summary': 'Storage status sent.'}
|
|
|
|
|
|
def _relay_client():
|
|
try:
|
|
from app.services import connect_client
|
|
return connect_client.get_client()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# ==================== the storage stream ====================
|
|
|
|
|
|
def build_status(app=None) -> dict | None:
|
|
"""One snapshot of where this panel's backups go and how they went.
|
|
|
|
Every field is something the panel actually knows. Anything it cannot
|
|
read is left out rather than sent as a zero: "0 bytes stored" and "we
|
|
could not list the bucket" are different answers.
|
|
"""
|
|
config = StorageProviderService.get_config()
|
|
provider = config.get('provider') or 'local'
|
|
block = config.get(provider, {}) if provider != 'local' else {}
|
|
prefix = config.get('managed_prefix') or block.get('path_prefix') or None
|
|
bucket = block.get('bucket')
|
|
|
|
payload = {
|
|
'type': 'status',
|
|
'destination': f'{bucket}/{prefix}' if bucket else None,
|
|
'prefix': f'{prefix.rstrip("/")}/' if prefix else None,
|
|
'managed_by_cloud': bool(config.get('managed_by_cloud')),
|
|
}
|
|
|
|
if provider != 'local':
|
|
try:
|
|
stats = StorageProviderService.get_remote_stats()
|
|
payload['remote_bytes'] = stats.get('remote_size')
|
|
payload['remote_objects'] = stats.get('remote_count')
|
|
except Exception as exc:
|
|
payload['error'] = f'Could not list the destination: {exc}'
|
|
|
|
policies, last_run, last_ok = _policy_rows(app)
|
|
if policies is not None:
|
|
payload['policies'] = policies
|
|
payload['last_backup_at'] = _iso(last_run)
|
|
payload['last_backup_ok'] = last_ok
|
|
return payload
|
|
|
|
|
|
def _policy_rows(app=None):
|
|
"""(rows, newest run time, whether it succeeded) from the panel's own
|
|
backup policies. Returns (None, None, None) when there is no app context
|
|
to read them in — the snapshot still carries the destination."""
|
|
try:
|
|
from app.models.backup_policy import BackupPolicy
|
|
except Exception:
|
|
return None, None, None
|
|
|
|
def collect():
|
|
rows = []
|
|
newest = None
|
|
newest_ok = None
|
|
for p in BackupPolicy.query.all():
|
|
rows.append({
|
|
'name': f'{p.target_type}:{p.target_id}',
|
|
'last_run_at': _iso(p.last_run_at),
|
|
'status': p.last_status,
|
|
'size': int(p.last_size) if p.last_size is not None else None,
|
|
'remote_copy': bool(p.remote_copy),
|
|
})
|
|
if p.last_run_at is not None and (newest is None or p.last_run_at > newest):
|
|
newest = p.last_run_at
|
|
newest_ok = (p.last_status == 'success')
|
|
return rows, newest, newest_ok
|
|
|
|
try:
|
|
if app is not None:
|
|
with app.app_context():
|
|
return collect()
|
|
return collect()
|
|
except Exception as exc:
|
|
logger.debug('Connect storage: could not read backup policies (%s)', exc)
|
|
return None, None, None
|
|
|
|
|
|
def status_frame(stream_id: str, snapshot: dict) -> dict:
|
|
return {'s': stream_id, 't': 'open', 'k': 'storage', 'p': snapshot}
|
|
|
|
|
|
def is_managed_by_cloud() -> bool:
|
|
"""True while ServerKit Cloud owns this panel's backup destination. The
|
|
local Backups > Storage form reads this and goes read-only."""
|
|
try:
|
|
return bool(StorageProviderService.get_config().get('managed_by_cloud'))
|
|
except Exception:
|
|
return False
|