mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 00:27:11 +09:00
DeploymentJobService.run_job dispatched on a hardcoded switch over three core kinds, so a plugin could not own work that behaves like a deployment. Its only option was the generic job system, which gives no step plan, no streaming console and no place in Deploy Activity — the reason promoting a WordPress environment, which is deploy-shaped in every respect, is invisible in the one page you open to see what ran. plugins_sdk.deploys lets a plugin register a kind and start one. The job is a real DeploymentJob, so it inherits the Deploy Console, live logs, retry and a row in the feed with no frontend work at all — those surfaces key off the job, not off any particular kind, and the feed already renders an unknown kind by its id. Three things the SDK guarantees, each pinned by a test: - Core kinds cannot be hijacked. A plugin registering app_deploy would silently take over real deployments. - A handler that raises still ends the job visibly failed. Third-party code must not be able to leave a job spinning at 'running' with no explanation — the same promise _reconcile_interrupted_jobs makes for a lost process, extended to a lost handler. - A handler that finalises the job itself keeps that outcome, even if its return value contradicts it. deploys.steps() advances the job through its plan as the handler works, so the console's pipeline strip and the feed's tick bar fill in, and a failure records which step failed rather than only a stack trace. The implementation is deploys_sdk.py, not deploys.py: a submodule of that name is bound as a package attribute on import and would shadow the lazy accessor, handing callers the module instead of the SDK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
151 lines
6.2 KiB
Python
151 lines
6.2 KiB
Python
"""Deployment kinds contributed by plugins (plugins_sdk.deploys).
|
|
|
|
Covers the promise the SDK makes: a plugin's work becomes a real DeploymentJob,
|
|
so it inherits the Deploy Console, the activity feed and retry without any
|
|
frontend change — and a misbehaving handler can never leave a job stuck
|
|
'running'.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from app import db
|
|
from app.models.deployment_job import DeploymentJob
|
|
from app.services import deploy_kind_registry
|
|
from app.services.deployment_job_service import DeploymentJobService
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_registry():
|
|
deploy_kind_registry.clear()
|
|
yield
|
|
deploy_kind_registry.clear()
|
|
|
|
|
|
class TestRegistry:
|
|
def test_registers_and_resolves(self):
|
|
handler = lambda job: {'success': True}
|
|
deploy_kind_registry.register('demo.thing', handler)
|
|
assert deploy_kind_registry.get('demo.thing') is handler
|
|
assert 'demo.thing' in deploy_kind_registry.kinds()
|
|
|
|
def test_core_kinds_cannot_be_hijacked(self):
|
|
# A plugin overriding app_deploy would silently take over real
|
|
# deployments, so this is refused rather than merely discouraged.
|
|
for kind in ('app_deploy', 'template_install', 'demo_deploy'):
|
|
with pytest.raises(ValueError):
|
|
deploy_kind_registry.register(kind, lambda job: None)
|
|
|
|
def test_duplicate_registration_needs_replace(self):
|
|
deploy_kind_registry.register('demo.thing', lambda job: None)
|
|
with pytest.raises(ValueError):
|
|
deploy_kind_registry.register('demo.thing', lambda job: None)
|
|
deploy_kind_registry.register('demo.thing', lambda job: None, replace=True)
|
|
|
|
def test_rejects_non_callable(self):
|
|
with pytest.raises(ValueError):
|
|
deploy_kind_registry.register('demo.thing', 'not-callable')
|
|
|
|
|
|
class TestRun:
|
|
def test_start_creates_a_job_addressable_by_the_console(self, app):
|
|
with app.app_context():
|
|
deploy_kind_registry.register('demo.thing', lambda job: {'success': True})
|
|
result = DeploymentJobService.start_registered(
|
|
'demo.thing', steps=['One', 'Two'], wait=True,
|
|
)
|
|
assert result['success'] is True
|
|
|
|
job = DeploymentJob.query.get(result['job_id'])
|
|
assert job.kind == 'demo.thing'
|
|
assert job.status == 'succeeded'
|
|
# The step plan is what the console's pipeline strip and the feed's
|
|
# tick bar render from.
|
|
assert [s['name'] for s in job.get_plan()['steps']] == ['One', 'Two']
|
|
assert job.total_steps == 2
|
|
|
|
def test_start_refuses_an_unregistered_kind(self, app):
|
|
with app.app_context():
|
|
result = DeploymentJobService.start_registered('nobody.registered', wait=True)
|
|
assert result['success'] is False
|
|
assert 'no handler' in result['error'].lower()
|
|
|
|
def test_handler_returning_failure_marks_the_job_failed(self, app):
|
|
with app.app_context():
|
|
deploy_kind_registry.register(
|
|
'demo.thing', lambda job: {'success': False, 'error': 'nope'})
|
|
result = DeploymentJobService.start_registered('demo.thing', wait=True)
|
|
assert result['success'] is False
|
|
|
|
job = DeploymentJob.query.get(result['job_id'])
|
|
assert job.status == 'failed'
|
|
assert job.error_message == 'nope'
|
|
assert job.completed_at is not None
|
|
|
|
def test_raising_handler_fails_the_job_instead_of_wedging_it(self, app):
|
|
# Third-party code: an exception must still end the job visibly, or the
|
|
# UI shows a deployment spinning forever with no explanation.
|
|
with app.app_context():
|
|
def boom(job):
|
|
raise RuntimeError('handler exploded')
|
|
|
|
deploy_kind_registry.register('demo.thing', boom)
|
|
result = DeploymentJobService.start_registered('demo.thing', wait=True)
|
|
assert result['success'] is False
|
|
|
|
job = DeploymentJob.query.get(result['job_id'])
|
|
assert job.status == 'failed'
|
|
assert 'handler exploded' in job.error_message
|
|
assert job.completed_at is not None
|
|
|
|
def test_handler_may_finalise_the_job_itself(self, app):
|
|
with app.app_context():
|
|
def finalise(job):
|
|
job.status = 'failed'
|
|
job.error_message = 'set by handler'
|
|
db.session.commit()
|
|
return {'success': True} # deliberately contradicts itself
|
|
|
|
deploy_kind_registry.register('demo.thing', finalise)
|
|
result = DeploymentJobService.start_registered('demo.thing', wait=True)
|
|
|
|
job = DeploymentJob.query.get(result['job_id'])
|
|
# A handler that took ownership of the outcome keeps it.
|
|
assert job.status == 'failed'
|
|
assert job.error_message == 'set by handler'
|
|
assert result['success'] is False
|
|
|
|
|
|
class TestSdkFacade:
|
|
def test_sdk_exposes_register_start_and_kinds(self, app):
|
|
from app import plugins_sdk
|
|
|
|
with app.app_context():
|
|
plugins_sdk.deploys.register('demo.sdk', lambda job: {'success': True})
|
|
assert 'demo.sdk' in plugins_sdk.deploys.kinds()
|
|
|
|
result = plugins_sdk.deploys.start('demo.sdk', steps=['Only step'], wait=True)
|
|
assert result['success'] is True
|
|
assert DeploymentJob.query.get(result['job_id']).status == 'succeeded'
|
|
|
|
def test_steps_helper_advances_progress_and_records_the_failing_step(self, app):
|
|
from app import plugins_sdk
|
|
|
|
with app.app_context():
|
|
def two_steps(job):
|
|
with plugins_sdk.deploys.steps(job) as step:
|
|
with step('First'):
|
|
pass
|
|
with step('Second'):
|
|
raise RuntimeError('second failed')
|
|
|
|
plugins_sdk.deploys.register('demo.steps', two_steps)
|
|
result = plugins_sdk.deploys.start(
|
|
'demo.steps', steps=['First', 'Second'], wait=True)
|
|
|
|
job = DeploymentJob.query.get(result['job_id'])
|
|
assert job.status == 'failed'
|
|
# Progress stopped on the step that failed — that is what the
|
|
# console highlights and the feed's tick bar shows in red.
|
|
assert job.current_step == 2
|
|
assert job.current_step_name == 'Second'
|