Files
ServerKit/backend/tests/test_worker_processes.py
Juan Denis 4fdd7e31d6 Run Procfile workers beside a build-pack app
Only the Procfile web: line was read, so worker: and scheduler:
never ran. Each now runs as serverkit-app-<id>-<process> from the
same image with the app's env and volumes, no port, and a restart
policy; start, stop and restart follow the web container, and a line
removed from the Procfile goes on the next deploy. Single-container
apps also join the shared service network, which only compose apps
did, and the app's container list now shows the web and its workers.
2026-09-27 21:03:59 -04:00

107 lines
5.0 KiB
Python

"""Procfile worker processes for build-pack apps (plan 86 §C3).
Hermetic half: parsing, the plan, the preview compose, and the exact docker
commands a deploy issues. The real-docker half (a worker actually runs with
the app's env, and a removed line takes its container away) is the
docker-builds leg in test_real_worker_processes_docker.py.
"""
import json
import pytest
import yaml
from app import db
from app.services.buildpack_service import BuildpackService
from app.services.docker_service import DockerService
from app.services.worker_process_service import (
WorkerProcessService, container_name, parse_procfile, worker_processes)
from tests.factories import make_application
PROCFILE = """# processes
web: gunicorn app:app --bind 0.0.0.0:$PORT
worker: celery -A app worker --loglevel=info
release: python manage.py migrate
Scheduler : celery -A app beat
web: ignored-second-definition
"""
def test_parse_keeps_the_first_definition_and_skips_comments():
assert parse_procfile(PROCFILE) == {
'web': 'gunicorn app:app --bind 0.0.0.0:$PORT',
'worker': 'celery -A app worker --loglevel=info',
'release': 'python manage.py migrate',
'scheduler': 'celery -A app beat',
}
def test_web_and_release_are_not_workers():
assert list(worker_processes(PROCFILE)) == ['worker', 'scheduler']
def test_the_plan_carries_the_processes_and_the_web_command(tmp_path):
(tmp_path / 'Procfile').write_text(PROCFILE)
(tmp_path / 'requirements.txt').write_text('flask\n')
(tmp_path / 'app.py').write_text('')
plan = BuildpackService.detect(str(tmp_path))
assert plan['start_command'] == 'gunicorn app:app --bind 0.0.0.0:$PORT'
assert plan['processes'] == {'worker': 'celery -A app worker --loglevel=info',
'scheduler': 'celery -A app beat'}
def test_preview_compose_runs_workers_from_the_same_image_without_ports(tmp_path):
plan = {'port': 8000, 'processes': {'worker': 'celery -A app worker'}}
compose = yaml.safe_load(BuildpackService.generate_compose(plan, 'Shop'))
web, worker = compose['services']['shop'], compose['services']['shop-worker']
assert worker['image'] == web['image'] == 'serverkit-shop:latest'
assert worker['command'] == ['sh', '-c', 'celery -A app worker']
assert 'ports' not in worker and 'build' not in worker
class TestDeploy:
@pytest.fixture
def site(self, app, tmp_path):
(tmp_path / 'Procfile').write_text(PROCFILE)
return make_application(db, name='shop', root_path=str(tmp_path))
def test_replaces_old_workers_and_starts_one_per_process(self, site, fake_subprocess, monkeypatch):
monkeypatch.setattr('app.services.service_connection_service.ServiceConnectionService'
'.needs_shared_network', classmethod(lambda cls, a: False))
prefix = f'serverkit-app-{site.id}-'
fake_subprocess.script(['docker', 'ps'], stdout=f'{prefix}old\n')
fake_subprocess.script(['docker', 'stop'])
fake_subprocess.script(['docker', 'rm'])
fake_subprocess.script(['docker', 'run'], stdout='cid\n')
result = WorkerProcessService.deploy(site, 'img:1', {'A': '1'}, ['vol:/data'])
assert result == {'started': ['worker', 'scheduler'], 'failed': {}}
cmds = fake_subprocess.commands()
assert any(c[:2] == ['docker', 'stop'] and f'{prefix}old' in c for c in cmds)
assert ['docker', 'rm', f'{prefix}old'] in [c[:3] for c in cmds]
runs = [c for c in cmds if c[:2] == ['docker', 'run']]
assert len(runs) == 2
worker = runs[0]
assert worker[worker.index('--name') + 1] == container_name(site, 'worker')
assert worker[-4:] == ['img:1', 'sh', '-c', 'celery -A app worker --loglevel=info']
assert '-p' not in worker, 'a worker publishes no port'
assert 'A=1' in worker and 'vol:/data' in worker
def test_a_failing_worker_is_reported_not_raised(self, site, fake_subprocess, monkeypatch):
monkeypatch.setattr('app.services.service_connection_service.ServiceConnectionService'
'.needs_shared_network', classmethod(lambda cls, a: False))
fake_subprocess.script(['docker', 'ps'], stdout='')
fake_subprocess.script(['docker', 'run'], returncode=125, stderr='no such image')
result = WorkerProcessService.deploy(site, 'img:1', {}, [])
assert result['started'] == [] and set(result['failed']) == {'worker', 'scheduler'}
def test_build_pack_containers_are_listed_web_first(app, fake_subprocess):
site = make_application(db, name='shop', buildpack_type='python', root_path='/nonexistent')
rows = [{'ID': 'w1', 'Names': f'serverkit-app-{site.id}-worker', 'State': 'running'},
{'ID': 'm1', 'Names': f'serverkit-app-{site.id}', 'State': 'running'}]
fake_subprocess.script(['docker', 'compose'], stdout='')
fake_subprocess.script(['docker', 'ps'], stdout='\n'.join(json.dumps(r) for r in rows))
listed = DockerService.get_all_app_containers(site)
assert [(c['id'], c['service']) for c in listed] == [('m1', 'web'), ('w1', 'worker')]