mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-26 04:57:08 +09:00
- Server onboarding completion (C8): a pure pre-flight compatibility check
(arch/distro/kernel/cpu/memory) gating validate; real-but-guarded
distro-aware install_prerequisites/install_docker/pair_agent steps that
no-op safely on dev/Windows and never raise; audit + best-effort
notifications on each transition; and a live auto-scrolling log trail +
elapsed timer in the onboarding wizard.
- Fleet-wide proxy dashboard (C6): ProxyStackService.fleet_overview aggregates
every server's proxy posture (configured stack vs host Nginx default) behind
GET /api/v1/servers/proxy/overview, surfaced as a new Fleet Proxy page in the
Servers tab group with summary tiles + click-through to each server's proxy.
- Polymorphic shared resources depth (C9): hierarchical variable resolution
(workspace < project < environment < direct attachment < resource) with
source provenance and {{group.KEY}} interpolation, an audit trail on every
tag/group/variable/attachment mutation, GET /api/v1/shared/resolved/
hierarchical, and the reusable Tags/Environment-Variables panels embedded on
the application and server detail pages.
- Docs & coverage: docs/ENHANCEMENTS.md (brand-neutral guide to all ten
capabilities + verified endpoints), README/CHANGELOG updates, and a
cross-cutting integration test asserting the new endpoints are wired.
Full backend suite: 924 passed, 14 skipped; frontend builds clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
343 lines
12 KiB
Python
343 lines
12 KiB
Python
"""Tests for the server onboarding state machine (Phase 1).
|
|
|
|
Under the testing config the job consumer is disabled, so the service is driven
|
|
synchronously: `start()` validates + advances all the way to `ready` (the steps
|
|
after `validating` are idempotent stubs that chain via `advance`).
|
|
"""
|
|
import pytest
|
|
|
|
from app import db
|
|
from app.models.audit_log import AuditLog
|
|
from app.models.server import Server
|
|
from app.models.server_onboarding_log import ServerOnboardingLog
|
|
from app.services.server_onboarding_service import ServerOnboardingService as SOS
|
|
|
|
|
|
def _make_server(**overrides):
|
|
data = dict(
|
|
name='Test Server',
|
|
hostname='test.example.com',
|
|
ip_address='203.0.113.10',
|
|
)
|
|
data.update(overrides)
|
|
server = Server(**data)
|
|
db.session.add(server)
|
|
db.session.commit()
|
|
return server
|
|
|
|
|
|
def test_start_runs_full_pipeline_to_ready(app):
|
|
with app.app_context():
|
|
server = _make_server()
|
|
assert server.onboarding_state in (None, 'pending')
|
|
|
|
status = SOS.start(server.id)
|
|
|
|
# Synchronous in testing — should reach the terminal ready state.
|
|
assert status['state'] == SOS.STATE_READY
|
|
assert status['is_terminal'] is True
|
|
|
|
refreshed = Server.query.get(server.id)
|
|
assert refreshed.onboarding_state == SOS.STATE_READY
|
|
assert refreshed.onboarding_updated_at is not None
|
|
|
|
# Logs were written for each lifecycle step.
|
|
states_logged = {
|
|
row.state for row in
|
|
ServerOnboardingLog.query.filter_by(server_id=server.id).all()
|
|
}
|
|
assert SOS.STATE_VALIDATING in states_logged
|
|
assert SOS.STATE_INSTALLING_PREREQS in states_logged
|
|
assert SOS.STATE_INSTALLING_DOCKER in states_logged
|
|
assert SOS.STATE_PAIRING_AGENT in states_logged
|
|
assert SOS.STATE_READY in states_logged
|
|
|
|
|
|
def test_progress_snapshot_mirrored_on_server(app):
|
|
with app.app_context():
|
|
server = _make_server()
|
|
SOS.start(server.id)
|
|
|
|
refreshed = Server.query.get(server.id)
|
|
progress = refreshed._onboarding_progress_list()
|
|
assert isinstance(progress, list)
|
|
assert len(progress) > 0
|
|
# Each snapshot entry carries the to_dict() shape.
|
|
assert {'state', 'status', 'message'} <= set(progress[0].keys())
|
|
|
|
# to_dict surfaces the new fields.
|
|
as_dict = refreshed.to_dict()
|
|
assert as_dict['onboarding_state'] == SOS.STATE_READY
|
|
assert isinstance(as_dict['onboarding_progress'], list)
|
|
assert as_dict['onboarding_updated_at'] is not None
|
|
|
|
|
|
def test_validation_fails_without_endpoint_or_agent(app):
|
|
with app.app_context():
|
|
# No hostname/ip and no agent — validation must fail.
|
|
server = _make_server(hostname=None, ip_address=None, agent_id=None)
|
|
|
|
status = SOS.start(server.id)
|
|
|
|
assert status['state'] == SOS.STATE_FAILED
|
|
refreshed = Server.query.get(server.id)
|
|
assert refreshed.onboarding_state == SOS.STATE_FAILED
|
|
|
|
failed_logs = ServerOnboardingLog.query.filter_by(
|
|
server_id=server.id, status=ServerOnboardingLog.STATUS_FAILED
|
|
).all()
|
|
assert len(failed_logs) >= 1
|
|
assert failed_logs[0].state == SOS.STATE_VALIDATING
|
|
|
|
|
|
def test_retry_recovers_from_failed(app):
|
|
with app.app_context():
|
|
server = _make_server(hostname=None, ip_address=None, agent_id=None)
|
|
SOS.start(server.id)
|
|
assert Server.query.get(server.id).onboarding_state == SOS.STATE_FAILED
|
|
|
|
# Give it a reachable endpoint, then retry — should now complete.
|
|
server = Server.query.get(server.id)
|
|
server.hostname = 'recovered.example.com'
|
|
db.session.commit()
|
|
|
|
status = SOS.retry(server.id)
|
|
assert status['state'] == SOS.STATE_READY
|
|
assert Server.query.get(server.id).onboarding_state == SOS.STATE_READY
|
|
|
|
# A "Retrying onboarding" log row exists.
|
|
retry_logs = [
|
|
r for r in ServerOnboardingLog.query.filter_by(server_id=server.id).all()
|
|
if r.message and 'Retry' in r.message
|
|
]
|
|
assert len(retry_logs) >= 1
|
|
|
|
|
|
def test_retry_on_non_failed_is_noop(app):
|
|
with app.app_context():
|
|
server = _make_server()
|
|
SOS.start(server.id) # -> ready
|
|
status = SOS.retry(server.id)
|
|
# Already ready; retry just reports current status.
|
|
assert status['state'] == SOS.STATE_READY
|
|
|
|
|
|
def test_transition_validation_helper(app):
|
|
with app.app_context():
|
|
assert SOS.is_valid_transition(SOS.STATE_PENDING, SOS.STATE_VALIDATING)
|
|
assert SOS.is_valid_transition(
|
|
SOS.STATE_VALIDATING, SOS.STATE_INSTALLING_PREREQS)
|
|
assert not SOS.is_valid_transition(
|
|
SOS.STATE_PENDING, SOS.STATE_READY)
|
|
# Any non-ready state can fail.
|
|
assert SOS.is_valid_transition(
|
|
SOS.STATE_INSTALLING_DOCKER, SOS.STATE_FAILED)
|
|
|
|
|
|
def test_get_status_unknown_server_raises(app):
|
|
with app.app_context():
|
|
with pytest.raises(ValueError):
|
|
SOS.get_status('does-not-exist')
|
|
|
|
|
|
def test_register_jobs_is_callable(app):
|
|
with app.app_context():
|
|
# Should not raise; registers the advance handler.
|
|
SOS.register_jobs()
|
|
from app.jobs import registry
|
|
from app.services.server_onboarding_service import ONBOARDING_JOB_KIND
|
|
assert registry.is_registered(ONBOARDING_JOB_KIND)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# pre_flight_check — PURE function matrices (no app context needed)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_preflight_fully_compatible_host():
|
|
result = SOS.pre_flight_check({
|
|
'architecture': 'x86_64',
|
|
'os_type': 'linux',
|
|
'distro': 'ubuntu',
|
|
'kernel': '5.15.0-89-generic',
|
|
'cpu_cores': 4,
|
|
'total_memory': 8 * 1024 * 1024 * 1024,
|
|
})
|
|
assert result['compatible'] is True
|
|
assert result['blocking'] == []
|
|
by_name = {c['name']: c for c in result['checks']}
|
|
assert by_name['architecture']['ok'] is True
|
|
assert by_name['distro']['ok'] is True
|
|
assert by_name['kernel']['ok'] is True
|
|
assert by_name['cpu']['ok'] is True
|
|
assert by_name['memory']['ok'] is True
|
|
|
|
|
|
def test_preflight_arm64_alias_supported():
|
|
result = SOS.pre_flight_check({'architecture': 'aarch64', 'os_type': 'linux'})
|
|
assert result['compatible'] is True
|
|
by_name = {c['name']: c for c in result['checks']}
|
|
assert by_name['architecture']['ok'] is True
|
|
|
|
|
|
def test_preflight_unsupported_arch_blocks():
|
|
result = SOS.pre_flight_check({'architecture': 'ppc64le', 'os_type': 'linux'})
|
|
assert result['compatible'] is False
|
|
assert 'architecture' in result['blocking']
|
|
|
|
|
|
def test_preflight_non_linux_os_blocks():
|
|
result = SOS.pre_flight_check({
|
|
'architecture': 'x86_64', 'os_type': 'windows',
|
|
})
|
|
assert result['compatible'] is False
|
|
assert 'os' in result['blocking']
|
|
|
|
|
|
def test_preflight_old_kernel_blocks():
|
|
result = SOS.pre_flight_check({
|
|
'architecture': 'x86_64', 'os_type': 'linux', 'kernel': '2.6.32',
|
|
})
|
|
assert result['compatible'] is False
|
|
assert 'kernel' in result['blocking']
|
|
|
|
|
|
def test_preflight_too_little_memory_blocks():
|
|
result = SOS.pre_flight_check({
|
|
'architecture': 'x86_64', 'os_type': 'linux',
|
|
'total_memory': 128 * 1024 * 1024, # 128 MiB
|
|
})
|
|
assert result['compatible'] is False
|
|
assert 'memory' in result['blocking']
|
|
|
|
|
|
def test_preflight_zero_cpu_blocks():
|
|
result = SOS.pre_flight_check({
|
|
'architecture': 'x86_64', 'os_type': 'linux', 'cpu_cores': 0,
|
|
})
|
|
assert result['compatible'] is False
|
|
assert 'cpu' in result['blocking']
|
|
|
|
|
|
def test_preflight_sparse_info_is_unknown_not_blocking():
|
|
# Empty system_info: nothing is known, so nothing blocks.
|
|
result = SOS.pre_flight_check({})
|
|
assert result['compatible'] is True
|
|
assert result['blocking'] == []
|
|
# All checks are "unknown" (ok is None).
|
|
assert all(c['ok'] is None for c in result['checks'])
|
|
|
|
|
|
def test_preflight_unrecognized_distro_blocks():
|
|
result = SOS.pre_flight_check({
|
|
'architecture': 'x86_64', 'os_type': 'linux', 'distro': 'plan9',
|
|
})
|
|
assert result['compatible'] is False
|
|
assert 'distro' in result['blocking']
|
|
|
|
|
|
def test_preflight_none_input_safe():
|
|
# None must not crash and must be treated as fully-unknown.
|
|
result = SOS.pre_flight_check(None)
|
|
assert result['compatible'] is True
|
|
|
|
|
|
def test_preflight_stringy_values_coerced():
|
|
result = SOS.pre_flight_check({
|
|
'architecture': 'amd64', 'os_type': 'linux',
|
|
'cpu_cores': '2', 'total_memory': str(2 * 1024 * 1024 * 1024),
|
|
})
|
|
assert result['compatible'] is True
|
|
by_name = {c['name']: c for c in result['checks']}
|
|
assert by_name['cpu']['ok'] is True
|
|
assert by_name['memory']['ok'] is True
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Real-but-guarded steps + audit emission
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_incompatible_host_fails_at_validate(app):
|
|
with app.app_context():
|
|
# Reachable endpoint but a known-bad architecture -> preflight blocks.
|
|
server = _make_server(architecture='ppc64le', os_type='linux')
|
|
status = SOS.start(server.id)
|
|
assert status['state'] == SOS.STATE_FAILED
|
|
|
|
failed = ServerOnboardingLog.query.filter_by(
|
|
server_id=server.id, status=ServerOnboardingLog.STATUS_FAILED
|
|
).all()
|
|
assert any(f.state == SOS.STATE_VALIDATING for f in failed)
|
|
# The failure detail carries the pre-flight result.
|
|
detail = failed[0].get_detail()
|
|
assert detail.get('preflight', {}).get('compatible') is False
|
|
|
|
|
|
def test_guarded_steps_reach_ready_on_dev(app):
|
|
# On Windows/dev (and on Linux without the tooling) every install/pair step
|
|
# degrades to a safe success, so the pipeline still completes.
|
|
with app.app_context():
|
|
server = _make_server()
|
|
status = SOS.start(server.id)
|
|
assert status['state'] == SOS.STATE_READY
|
|
|
|
# Each step logged a succeeded row with a human-readable message.
|
|
succeeded = ServerOnboardingLog.query.filter_by(
|
|
server_id=server.id, status=ServerOnboardingLog.STATUS_SUCCEEDED
|
|
).all()
|
|
states_done = {r.state for r in succeeded}
|
|
assert SOS.STATE_INSTALLING_PREREQS in states_done
|
|
assert SOS.STATE_INSTALLING_DOCKER in states_done
|
|
assert SOS.STATE_PAIRING_AGENT in states_done
|
|
|
|
|
|
def test_install_prerequisites_idempotent_detail(app):
|
|
# Calling the underlying helper twice must never raise and must return a
|
|
# (message, detail) pair both times (degrades on dev).
|
|
with app.app_context():
|
|
msg1, detail1 = SOS._do_install_prerequisites()
|
|
msg2, detail2 = SOS._do_install_prerequisites()
|
|
assert isinstance(msg1, str) and isinstance(detail1, dict)
|
|
assert isinstance(msg2, str) and isinstance(detail2, dict)
|
|
|
|
|
|
def test_pair_agent_records_waiting_when_not_enrolled(app):
|
|
with app.app_context():
|
|
# Endpoint present (so validate passes) but no agent credentials.
|
|
server = _make_server(agent_id=None)
|
|
SOS.start(server.id)
|
|
|
|
pair_logs = ServerOnboardingLog.query.filter_by(
|
|
server_id=server.id, state=SOS.STATE_PAIRING_AGENT,
|
|
status=ServerOnboardingLog.STATUS_SUCCEEDED,
|
|
).all()
|
|
assert len(pair_logs) >= 1
|
|
detail = pair_logs[-1].get_detail()
|
|
assert detail.get('registered') is False
|
|
assert 'waiting' in detail
|
|
|
|
|
|
def test_audit_rows_written_for_transitions(app):
|
|
with app.app_context():
|
|
server = _make_server()
|
|
SOS.start(server.id)
|
|
|
|
actions = {a.action for a in AuditLog.query.all()}
|
|
assert 'server.onboarding.started' in actions
|
|
assert 'server.onboarding.step' in actions
|
|
assert 'server.onboarding.completed' in actions
|
|
|
|
# The started audit stashes the server id inside details (target_id is
|
|
# an Integer column, server ids are UUID strings).
|
|
started = AuditLog.query.filter_by(
|
|
action='server.onboarding.started').first()
|
|
assert started is not None
|
|
assert started.get_details().get('server_id') == server.id
|
|
|
|
|
|
def test_audit_failure_row_written(app):
|
|
with app.app_context():
|
|
server = _make_server(hostname=None, ip_address=None, agent_id=None)
|
|
SOS.start(server.id)
|
|
actions = {a.action for a in AuditLog.query.all()}
|
|
assert 'server.onboarding.failed' in actions
|