Files
ServerKit/backend/tests/test_compose_env_overlay.py
Juan Denis 98ad0481a5 fix(deployments): stop stuck 0% installs, deploy repo apps on create, unify install UX
- deployment_job_logs.id: BigInteger -> with_variant(Integer, 'sqlite');
  BIGINT PK never autoincrements on SQLite, so the first log insert crashed
  every fresh install (jobs stuck 'running' at step 0 with no logs)
- migration 076 rebuilds the table on existing SQLite databases
- runner: first status/log/telemetry inside try, non-raising log(),
  rollback-safe failure marking — any failure now fails the job visibly
- service: guard enqueue, crash-proof run_job, fail jobs left 'running'
  across restarts at boot
- repo-based services (POST /apps/from-repository) now enqueue a real
  'app_deploy' DeploymentJob (build + start via DeploymentService.deploy)
  and return deploy_job_id
- compose override injects restart: unless-stopped for services without a
  policy, so apps come back after reboot
- GET /deployment-jobs gains an app_id filter
- frontend: shared DeploymentJobProgress component (timeout + visible poll
  errors) used by the Templates InstallModal and the service Logs tab;
  repo wizard navigates to the Logs tab to watch the deploy live; install
  success lands on the Logs tab; failure toasts surface real server errors
2026-07-21 22:57:33 -04:00

205 lines
8.3 KiB
Python

"""Compose env overlay: the managed override that injects an app's effective
environment (shared variable groups under local env vars) into every compose
service, and that compose_up includes it."""
import os
import yaml
BASE_COMPOSE = """\
services:
web:
image: nginx:latest
ports:
- "8080:80"
db:
image: postgres:16
"""
def _make_compose_app(tmp_path, workspace_id=99):
from app import db
from app.models.application import Application
root = str(tmp_path)
with open(os.path.join(root, 'docker-compose.yml'), 'w', encoding='utf-8') as f:
f.write(BASE_COMPOSE)
a = Application(
name='svc', app_type='docker', status='running', user_id=1,
root_path=root, managed_by='docker_compose', workspace_id=workspace_id,
)
db.session.add(a)
db.session.commit()
return a
def _set_local(app_id, key, value, is_secret=False, target_service=None):
from app import db
from app.models import EnvironmentVariable
ev = EnvironmentVariable(application_id=app_id, key=key, is_secret=is_secret,
target_service=target_service)
ev.value = value
db.session.add(ev)
db.session.commit()
def _make_scoped_group(scope_type, scope_id, key, value, target_service=None):
from app import db
from app.models.shared_resource import SharedVariableGroup, SharedVariable
g = SharedVariableGroup(scope_type=scope_type, scope_id=str(scope_id), name='g')
db.session.add(g)
db.session.commit()
v = SharedVariable(group_id=g.id, key=key, is_secret=False, target_service=target_service)
v.value = value
db.session.add(v)
db.session.commit()
def test_find_base_compose(app, tmp_path):
from app.services.compose_env_service import ComposeEnvService
assert ComposeEnvService.find_base_compose(str(tmp_path)) is None
open(os.path.join(str(tmp_path), 'docker-compose.yml'), 'w').close()
assert ComposeEnvService.find_base_compose(str(tmp_path)) == 'docker-compose.yml'
# explicit compose_file is authoritative
assert ComposeEnvService.find_base_compose(str(tmp_path), 'custom.yml') == 'custom.yml'
def test_refresh_writes_overlay_for_every_service(app, tmp_path):
from app.services.compose_env_service import ComposeEnvService
with app.app_context():
a = _make_compose_app(tmp_path, workspace_id=99)
_make_scoped_group('workspace', 99, 'SHARED_KEY', 'shared_val')
_set_local(a.id, 'LOCAL_KEY', 'local_val')
_set_local(a.id, 'OVERRIDE_ME', 'local_wins')
_make_scoped_group('workspace', 99, 'OVERRIDE_ME', 'group_loses')
path = ComposeEnvService.refresh_for_project(str(tmp_path))
assert path and os.path.exists(path)
with open(path, encoding='utf-8') as f:
data = yaml.safe_load(f)
for svc in ('web', 'db'):
env = data['services'][svc]['environment']
assert env['SHARED_KEY'] == 'shared_val' # shared group injected
assert env['LOCAL_KEY'] == 'local_val'
assert env['OVERRIDE_ME'] == 'local_wins' # local beat the shared group
def test_per_service_targeting(app, tmp_path):
from app.services.compose_env_service import ComposeEnvService
with app.app_context():
a = _make_compose_app(tmp_path, workspace_id=77)
# untargeted → both services
_set_local(a.id, 'COMMON', 'everywhere')
# local targeted at web only
_set_local(a.id, 'WEB_ONLY', 'w', target_service='web')
# shared var targeted at db only
_make_scoped_group('workspace', 77, 'DB_ONLY', 'd', target_service='db')
path = ComposeEnvService.refresh_for_project(str(tmp_path))
with open(path, encoding='utf-8') as f:
data = yaml.safe_load(f)
web = data['services']['web']['environment']
db = data['services']['db']['environment']
assert web['COMMON'] == 'everywhere' and db['COMMON'] == 'everywhere'
assert web['WEB_ONLY'] == 'w' and 'WEB_ONLY' not in db
assert db['DB_ONLY'] == 'd' and 'DB_ONLY' not in web
def test_effective_env_for_services_helper(app, tmp_path):
from app.services.env_service import EnvService
with app.app_context():
a = _make_compose_app(tmp_path, workspace_id=88)
_set_local(a.id, 'ALL', 'a')
_set_local(a.id, 'ONLY_DB', 'x', target_service='db')
per = EnvService.get_effective_env_for_services(a.id, ['web', 'db'])
assert per['web'] == {'ALL': 'a'}
assert per['db'] == {'ALL': 'a', 'ONLY_DB': 'x'}
def test_dollar_signs_are_escaped(app, tmp_path):
from app.services.compose_env_service import ComposeEnvService
with app.app_context():
a = _make_compose_app(tmp_path, workspace_id=5)
_set_local(a.id, 'PASS', 'a$b${X}')
path = ComposeEnvService.refresh_for_project(str(tmp_path))
with open(path, encoding='utf-8') as f:
data = yaml.safe_load(f)
# '$' doubled so compose interpolation leaves the literal value intact.
assert data['services']['web']['environment']['PASS'] == 'a$$b$${X}'
def test_no_env_removes_stale_override(app, tmp_path):
from app.services.compose_env_service import ComposeEnvService
with app.app_context():
a = _make_compose_app(tmp_path, workspace_id=1) # app with no env vars
# Every service already sets its own restart policy and there is no env
# to inject → nothing for the override to carry.
with open(os.path.join(str(tmp_path), 'docker-compose.yml'), 'w', encoding='utf-8') as f:
f.write("services:\n web:\n image: nginx:latest\n restart: always\n"
" db:\n image: postgres:16\n restart: \"no\"\n")
stale = ComposeEnvService.override_path(str(tmp_path))
with open(stale, 'w', encoding='utf-8') as f:
f.write('services: {}\n')
path = ComposeEnvService.refresh_for_project(str(tmp_path))
assert path is None
assert not os.path.exists(stale) # stale override cleaned up
def test_restart_policy_injected_for_services_without_one(app, tmp_path):
from app.services.compose_env_service import ComposeEnvService
with app.app_context():
_make_compose_app(tmp_path, workspace_id=1) # no env vars at all
# Base compose: web has no restart policy, db opts out explicitly.
with open(os.path.join(str(tmp_path), 'docker-compose.yml'), 'w', encoding='utf-8') as f:
f.write("services:\n web:\n image: nginx:latest\n"
" db:\n image: postgres:16\n restart: \"no\"\n")
path = ComposeEnvService.refresh_for_project(str(tmp_path))
assert path and os.path.exists(path)
with open(path, encoding='utf-8') as f:
data = yaml.safe_load(f)
assert data['services']['web']['restart'] == 'unless-stopped'
# Explicit policy respected — nothing else to inject for db.
assert 'db' not in data['services']
def test_non_app_dir_is_untouched(app, tmp_path):
from app.services.compose_env_service import ComposeEnvService
with app.app_context():
open(os.path.join(str(tmp_path), 'docker-compose.yml'), 'w').close()
# No Application has this root_path → leave it alone.
assert ComposeEnvService.refresh_for_project(str(tmp_path)) is None
assert not os.path.exists(ComposeEnvService.override_path(str(tmp_path)))
def test_compose_up_includes_override(app, tmp_path, monkeypatch):
from app.services import docker_service as ds_mod
from app.services.docker_service import DockerService
with app.app_context():
a = _make_compose_app(tmp_path, workspace_id=3)
_set_local(a.id, 'FOO', 'bar')
# Pin the compose binary so detection doesn't shell out.
monkeypatch.setattr(DockerService, '_compose_cmd', ['docker', 'compose'], raising=False)
captured = {}
class _Result:
returncode = 0
stdout = ''
stderr = ''
def _fake_run(cmd, **kwargs):
captured['cmd'] = cmd
return _Result()
monkeypatch.setattr(ds_mod.subprocess, 'run', _fake_run)
DockerService.compose_up(str(tmp_path))
cmd = captured['cmd']
assert cmd.count('-f') == 2
assert any('docker-compose.serverkit.yml' in str(c) for c in cmd)
assert cmd[-1] == 'up' or 'up' in cmd