Files
Juan DenisandClaude Opus 5 e10ecc87d0 feat(cli): add serverkit disk to report and reclaim disk space
A panel host filled to 100% (0 bytes free) purely from routine
`serverkit update` runs, and recovering it meant hand-running du and
sqlite. `serverkit disk` measures every reclaimable item and offers them
for selection: numbers, "safe", or "all".

Upgrade snapshots are grouped by timestamp and offered individually with
age and size, since one update writes both a tree backup and a database
copy under one stamp -- half a snapshot is not a restore point. They can
be picked by number, by age (--older-than N, or "older-than N" in the
prompt), or left to the "all-but-newest" default.

Two ordering rules are enforced regardless of selection order:

* filesystem reclaimers run BEFORE the database VACUUM. VACUUM rewrites
  the database into a temp file beside it, so it needs free space equal
  to the database -- on a full disk it is the one step that cannot go
  first. It refuses with a clear message rather than failing halfway.
* `jobs` rows are deleted before `queue_messages`, and any message still
  referenced by a surviving job is kept, so jobs.queue_message_id never
  dangles.

in_flight messages and pending/failed jobs are never touched. /tmp is
only swept for entries matching mktemp naming, owned by the invoking
user, over 24h old AND holding an unpacked ServerKit tree. Docker prunes
dangling images and build cache only -- never tagged images or volumes.

Sizes are measured rather than estimated wherever measurement is
possible: docker reports its own reclaimed tally (shared layers make
dangling-image sizes overlap), and the journal and package caches report
before/after deltas -- apt-get clean empties the cache before a naive
count could see it, which would otherwise report 0 freed for real work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 19:12:12 -04:00

201 lines
6.8 KiB
Python

"""--json output on the read-only CLI verbs: every listed command must emit
parseable JSON on stdout (scriptable/monitorable CLI), and the two flags that
conflict with table-only extras must refuse loudly."""
import json
import pytest
from click.testing import CliRunner
import cli as serverkit_cli
def _all_output(result):
out = result.output
try:
out += result.stderr
except (ValueError, AttributeError):
pass
return out
def _json_out(result):
assert result.exit_code == 0, _all_output(result)
return json.loads(result.output)
class FakeClient:
def __init__(self, responses):
self.responses = responses
self.calls = []
def get(self, path):
self.calls.append(('GET', path, None))
return self.responses[path]
def post(self, path, json_body=None):
self.calls.append(('POST', path, json_body))
return self.responses[path]
@pytest.fixture
def fake_api(monkeypatch):
def _install(responses):
fake = FakeClient(responses)
monkeypatch.setattr(serverkit_cli, '_api_client', lambda with_token=True: fake)
return fake
return _install
# ── API-backed verbs ─────────────────────────────────────────────────────────
def test_status_json(fake_api):
fake_api({'/system/health': {'status': 'ok', 'service': 'serverkit'}})
result = CliRunner().invoke(serverkit_cli.cli, ['status', '--json'])
data = _json_out(result)
assert data['health']['status'] == 'ok'
assert data['version']
def test_doctor_json_emits_raw_report(fake_api):
fake_api({'/doctor/run': {'report': {'checks': [
{'key': 'nginx', 'title': 'Nginx config', 'status': 'ok', 'detail': ''},
{'key': 'ssl', 'title': 'SSL cert', 'status': 'fail', 'detail': 'expired'},
]}}})
result = CliRunner().invoke(serverkit_cli.cli, ['doctor', '--json'])
data = _json_out(result)
assert [c['key'] for c in data['checks']] == ['nginx', 'ssl']
def test_doctor_json_refuses_repair(fake_api):
fake_api({})
result = CliRunner().invoke(serverkit_cli.cli, ['doctor', '--json', '--repair'])
assert result.exit_code == 1
assert '--repair' in _all_output(result)
def test_services_list_json(fake_api):
fake_api({'/processes/services': {'services': [
{'name': 'nginx', 'status': 'running', 'pid': 123},
]}})
result = CliRunner().invoke(serverkit_cli.cli, ['services', 'list', '--json'])
data = _json_out(result)
assert data['services'][0]['name'] == 'nginx'
def test_apps_list_json(fake_api):
fake_api({'/apps': {'apps': [
{'name': 'blog', 'app_type': 'wordpress', 'status': 'running'},
]}})
result = CliRunner().invoke(serverkit_cli.cli, ['apps', 'list', '--json'])
data = _json_out(result)
assert data['apps'][0]['app_type'] == 'wordpress'
def test_manifest_plan_json(fake_api):
fake = fake_api({'/manifests/plan': {'plan': {
'steps': [{'type': 'create', 'service': 'web', 'description': 'create app web'}],
'step_count': 1,
'summary': '1 change planned',
'issues': [],
}}})
result = CliRunner().invoke(
serverkit_cli.cli, ['manifest', 'plan', '--project', '7', '--json'])
data = _json_out(result)
assert ('POST', '/manifests/plan', {'project_id': 7}) in fake.calls
assert data['step_count'] == 1
assert data['steps'][0]['service'] == 'web'
# ── DIRECT (in-process) verbs ────────────────────────────────────────────────
def test_list_users_json(app):
from app import db
from app.models import User
user = User(email='j@test.local', username='juan', role='admin', is_active=True)
user.set_password('x')
db.session.add(user)
db.session.commit()
result = CliRunner().invoke(serverkit_cli.cli, ['list-users', '--json'])
data = _json_out(result)
assert data['users'] == [{
'id': user.id,
'username': 'juan',
'email': 'j@test.local',
'role': 'admin',
'is_active': True,
'is_locked': False,
}]
def test_list_apps_json_empty(app):
result = CliRunner().invoke(serverkit_cli.cli, ['list-apps', '--json'])
data = _json_out(result)
assert data == {'apps': []}
def test_list_apps_json_refuses_all(app):
result = CliRunner().invoke(serverkit_cli.cli, ['list-apps', '--json', '--all'])
assert result.exit_code == 1
assert '--all' in _all_output(result)
def test_list_servers_json(app, monkeypatch):
from app.services.remote_docker_service import RemoteDockerService
monkeypatch.setattr(
RemoteDockerService, 'get_available_servers',
staticmethod(lambda: [{'id': 'local', 'name': 'This server',
'status': 'online', 'is_local': True}]),
)
result = CliRunner().invoke(serverkit_cli.cli, ['list-servers', '--json'])
data = _json_out(result)
assert data['servers'][0]['id'] == 'local'
def test_db_status_json(app, monkeypatch):
from app.services.migration_service import MigrationService
monkeypatch.setattr(
MigrationService, 'get_status',
staticmethod(lambda: {'current_revision': '074', 'head_revision': '074',
'pending_count': 0, 'pending_migrations': [],
'needs_migration': False}),
)
result = CliRunner().invoke(serverkit_cli.cli, ['db-status', '--json'])
data = _json_out(result)
assert data['current_revision'] == '074'
assert data['pending_count'] == 0
def test_deployment_status_json(app, monkeypatch):
from app.services.deployment_job_service import DeploymentJobService
job = {'id': 'j-1', 'kind': 'template.install', 'status': 'succeeded',
'progress_percent': 100, 'target_server_name': 'local'}
monkeypatch.setattr(
DeploymentJobService, 'get_job',
staticmethod(lambda job_id, include_logs=False: job),
)
result = CliRunner().invoke(
serverkit_cli.cli, ['deployment-status', 'j-1', '--json'])
data = _json_out(result)
assert data['job']['status'] == 'succeeded'
def test_disk_json_is_a_read_only_report(app, monkeypatch):
"""`disk --json` with no selection flag reports and changes nothing."""
from app.services import disk_reclaim_service as svc
monkeypatch.setattr(svc, 'reclaim', lambda *a, **k: pytest.fail(
'disk --json without a selection flag must not reclaim anything'))
result = CliRunner().invoke(serverkit_cli.cli, ['disk', '--json'])
data = _json_out(result)
assert 'candidates' in data and 'disk' in data
keys = {c['key'] for c in data['candidates']}
assert {'upgrade-snapshots', 'telemetry'} <= keys
for cand in data['candidates']:
assert cand['safety'] in ('safe', 'review')