mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 16:47:01 +09:00
Comments, docstrings and a few strings cited plan numbers ("plan 29
#9", "plan 86 §B2") from a git-ignored folder, so they pointed at
nothing; one reached users in a bottleneck hint. Code and frontend
strings are unchanged apart from those citations (checked by AST and
token comparison). A vhost still carrying the old asset comment reads
as undrifted, and a guard test keeps new citations out.
285 lines
13 KiB
Python
285 lines
13 KiB
Python
"""Inject a managed app's effective environment into its Compose services.
|
|
|
|
Single-container apps get their env via ``docker run -e`` (see
|
|
``EnvService.get_effective_env`` + ``DeploymentService._deploy_docker``). Compose
|
|
apps instead get container env from the compose file's ``environment:`` block, so
|
|
to make shared variable groups + the app's own local env vars reach a compose
|
|
app's containers we write a **managed override compose file**
|
|
(``docker-compose.serverkit.yml``) next to the app's base compose. The override
|
|
adds an ``environment:`` overlay to every service; Docker Compose merges it on top
|
|
of the base (override wins on key collisions), so the effective env is
|
|
authoritative — matching the single-container behaviour. The override also
|
|
carries per-app resource limits and a ``restart: unless-stopped`` default for
|
|
services that don't set their own restart policy, so app containers survive a
|
|
reboot / daemon restart.
|
|
|
|
This is non-destructive: ServerKit owns only the override file and never edits the
|
|
user's / template's base compose. The override is regenerated on every
|
|
``compose up`` / ``compose restart`` and removed when the app has no effective env,
|
|
so it never goes stale. Everything here is best-effort and guarded — a failure to
|
|
build the overlay must never block a deploy; the caller simply falls back to the
|
|
plain base compose.
|
|
"""
|
|
import logging
|
|
import os
|
|
|
|
import yaml
|
|
|
|
from app.services.service_connection_service import SHARED_NETWORK
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ComposeEnvService:
|
|
"""Writes/owns the ServerKit env override for a managed compose app."""
|
|
|
|
OVERRIDE_NAME = 'docker-compose.serverkit.yml'
|
|
# Conventional base compose filenames, in the order Docker Compose itself
|
|
# prefers them during auto-discovery.
|
|
BASE_CANDIDATES = (
|
|
'docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml',
|
|
)
|
|
|
|
@classmethod
|
|
def find_base_compose(cls, project_path, compose_file=None):
|
|
"""Return the base compose file to use (a name/path), or None.
|
|
|
|
When ``compose_file`` is given it's authoritative; otherwise discover a
|
|
conventional base file in ``project_path``.
|
|
"""
|
|
if compose_file:
|
|
return compose_file
|
|
if not project_path:
|
|
return None
|
|
for name in cls.BASE_CANDIDATES:
|
|
if os.path.exists(os.path.join(project_path, name)):
|
|
return name
|
|
return None
|
|
|
|
@classmethod
|
|
def override_path(cls, project_path):
|
|
return os.path.join(project_path, cls.OVERRIDE_NAME)
|
|
|
|
@classmethod
|
|
def list_services(cls, app):
|
|
"""Service names declared in a managed compose app's base compose.
|
|
|
|
Used by the UI to offer a per-service targeting choice. Returns [] for
|
|
non-compose apps or when the base compose can't be read.
|
|
"""
|
|
try:
|
|
root = getattr(app, 'root_path', None)
|
|
if not root or not os.path.isdir(root):
|
|
return []
|
|
base = cls.find_base_compose(root, getattr(app, 'compose_file', None))
|
|
if not base:
|
|
return []
|
|
base_path = base if os.path.isabs(base) else os.path.join(root, base)
|
|
return cls._service_names(base_path)
|
|
except Exception: # pragma: no cover - defensive
|
|
return []
|
|
|
|
@staticmethod
|
|
def _app_for_project(project_path):
|
|
"""The managed Application whose root_path is this project dir, if any."""
|
|
try:
|
|
from app.models.application import Application
|
|
# Path-keyed: a tombstone whose root_path a new app reused would
|
|
# shadow the live app and the overlay would be written for the wrong one.
|
|
return Application.query_active().filter_by(root_path=project_path).first()
|
|
except Exception as e: # pragma: no cover - DB guard
|
|
logger.debug('compose overlay app lookup failed for %s: %s', project_path, e)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _services(base_compose_path):
|
|
"""Services declared in the base compose as {name: service-dict}
|
|
(empty dict on any error)."""
|
|
try:
|
|
with open(base_compose_path, 'r', encoding='utf-8') as f:
|
|
data = yaml.safe_load(f) or {}
|
|
services = data.get('services') or {}
|
|
if isinstance(services, dict):
|
|
return services
|
|
except Exception as e:
|
|
logger.debug('could not read services from %s: %s', base_compose_path, e)
|
|
return {}
|
|
|
|
@classmethod
|
|
def _service_names(cls, base_compose_path):
|
|
"""Service names declared in the base compose (empty list on any error)."""
|
|
return list(cls._services(base_compose_path).keys())
|
|
|
|
@staticmethod
|
|
def _limits_block(app):
|
|
"""Compose keys for the app's resource limits, or {} when unset/invalid."""
|
|
block = {}
|
|
try:
|
|
cpu = getattr(app, 'cpu_limit', None)
|
|
if cpu:
|
|
block['cpus'] = float(cpu)
|
|
mem = getattr(app, 'memory_limit', None)
|
|
if mem:
|
|
block['mem_limit'] = str(mem)
|
|
except (TypeError, ValueError):
|
|
return {}
|
|
return block
|
|
|
|
@staticmethod
|
|
def _escape(value):
|
|
"""Escape a value for a compose ``environment:`` entry.
|
|
|
|
Compose performs ``${VAR}`` interpolation on environment values, so a
|
|
literal ``$`` must be doubled to survive intact.
|
|
"""
|
|
return str(value).replace('$', '$$')
|
|
|
|
@classmethod
|
|
def render_override(cls, project_path, compose_file=None):
|
|
"""Compute the override that *should* exist for a project dir, without
|
|
touching disk. Extracted from :meth:`refresh_for_project` so drift
|
|
detection can render the expected file content in-memory.
|
|
|
|
Returns ``{'applies': bool, 'path': str|None, 'content': str|None}``:
|
|
``applies=False`` when this dir isn't a managed compose app (leave it
|
|
alone); ``content=None`` (with ``applies=True``) when no override should
|
|
exist. May raise — :meth:`refresh_for_project` wraps it.
|
|
"""
|
|
if not project_path or not os.path.isdir(project_path):
|
|
return {'applies': False, 'path': None, 'content': None}
|
|
|
|
app = cls._app_for_project(project_path)
|
|
if app is None:
|
|
# Not a managed app dir (e.g. a proxy stack) — leave it alone.
|
|
return {'applies': False, 'path': None, 'content': None}
|
|
|
|
base = cls.find_base_compose(project_path, compose_file)
|
|
if not base:
|
|
return {'applies': False, 'path': None, 'content': None}
|
|
override_path = cls.override_path(project_path)
|
|
base_path = base if os.path.isabs(base) else os.path.join(project_path, base)
|
|
services = cls._services(base_path)
|
|
service_names = list(services.keys())
|
|
if not service_names:
|
|
return {'applies': True, 'path': override_path, 'content': None}
|
|
|
|
from app.services.env_service import EnvService
|
|
# Per-service effective env: a variable lands on every service unless
|
|
# it targets a specific one (EnvironmentVariable/SharedVariable
|
|
# .target_service). Local env vars override shared variable groups.
|
|
per_service = EnvService.get_effective_env_for_services(app.id, service_names)
|
|
|
|
services_block = {}
|
|
for name in service_names:
|
|
svc_env = (per_service or {}).get(name) or {}
|
|
if not svc_env:
|
|
continue # nothing targeted at this service
|
|
services_block[name] = {
|
|
'environment': {k: cls._escape(v) for k, v in svc_env.items() if k}
|
|
}
|
|
|
|
# Per-app resource limits (task #23): cap the app's primary
|
|
# (first-declared) compose service. mem_limit/cpus apply with plain
|
|
# (non-swarm) docker compose.
|
|
limits = cls._limits_block(app)
|
|
if limits:
|
|
services_block.setdefault(service_names[0], {}).update(limits)
|
|
|
|
# Restart policy: services without an explicit `restart:` get
|
|
# `unless-stopped` so an app's containers come back after a reboot or
|
|
# Docker daemon restart (a service that sets its own policy — even
|
|
# `restart: "no"` — is left alone).
|
|
for name, svc in services.items():
|
|
if not (isinstance(svc, dict) and svc.get('restart')):
|
|
services_block.setdefault(name, {})['restart'] = 'unless-stopped'
|
|
|
|
# Shared service network: an app that offers connection
|
|
# properties (Redis, MinIO...) or consumes one through `fromService`
|
|
# joins SHARED_NETWORK so the two can reach each other by container
|
|
# name. `default` is listed too — naming any network on a service
|
|
# otherwise drops its implicit default one. Services with a
|
|
# `network_mode` can't join networks and are left alone.
|
|
joined = cls._shared_network_services(app, services)
|
|
for name in joined:
|
|
services_block.setdefault(name, {})['networks'] = {
|
|
'default': {}, SHARED_NETWORK: {}}
|
|
|
|
# Opt-in PgBouncer beside a PostgreSQL engine: a whole
|
|
# extra service, reachable from apps over the shared network only.
|
|
from app.services import pooler_service
|
|
pooler = pooler_service.sidecar(app, services)
|
|
if pooler:
|
|
pooler['networks'] = {'default': {}, SHARED_NETWORK: {}}
|
|
pooler['environment'] = {k: cls._escape(v) for k, v in pooler['environment'].items()}
|
|
services_block[pooler_service.POOLER_SERVICE] = pooler
|
|
joined = joined or [pooler_service.POOLER_SERVICE]
|
|
|
|
if not services_block:
|
|
# Nothing to inject → no override should exist.
|
|
return {'applies': True, 'path': override_path, 'content': None}
|
|
override = {'services': services_block}
|
|
if joined:
|
|
override['networks'] = {
|
|
SHARED_NETWORK: {'external': True, 'name': SHARED_NETWORK}}
|
|
|
|
header = (
|
|
'# Managed by ServerKit — do not edit.\n'
|
|
'# Injects the app\'s effective environment (shared variable groups\n'
|
|
'# under the app\'s own local env vars) into every compose service,\n'
|
|
'# plus any per-app resource limits on the primary service, a\n'
|
|
'# `restart: unless-stopped` default for services without one, and\n'
|
|
'# the shared service network when the app offers or uses one.\n'
|
|
'# Regenerated on every deploy; delete it and it will be recreated.\n'
|
|
)
|
|
content = header + yaml.safe_dump(override, default_flow_style=False, sort_keys=True)
|
|
return {'applies': True, 'path': override_path, 'content': content}
|
|
|
|
@staticmethod
|
|
def _shared_network_services(app, services):
|
|
"""Service names that join SHARED_NETWORK, or [] when the app neither
|
|
offers nor consumes a connectable service."""
|
|
try:
|
|
from app.services.service_connection_service import ServiceConnectionService
|
|
if not ServiceConnectionService.needs_shared_network(app):
|
|
return []
|
|
except Exception as e: # noqa: BLE001 - never block the overlay
|
|
logger.debug('shared-network check failed for app %s: %s', app.id, e)
|
|
return []
|
|
return [name for name, svc in services.items()
|
|
if not (isinstance(svc, dict) and svc.get('network_mode'))]
|
|
|
|
@classmethod
|
|
def refresh_for_project(cls, project_path, compose_file=None):
|
|
"""Regenerate (or remove) the env override for a project dir.
|
|
|
|
Returns the absolute override path when one was written, else None.
|
|
Best-effort and fully guarded — never raises.
|
|
"""
|
|
try:
|
|
spec = cls.render_override(project_path, compose_file)
|
|
if not spec['applies']:
|
|
return None
|
|
if spec['content'] is None:
|
|
# Nothing to inject → make sure no stale override lingers.
|
|
cls._remove_override(project_path)
|
|
return None
|
|
with open(spec['path'], 'w', encoding='utf-8') as f:
|
|
f.write(spec['content'])
|
|
if SHARED_NETWORK in spec['content']:
|
|
# External networks must exist before `compose up`.
|
|
from app.services.docker_service import DockerService
|
|
DockerService.ensure_network(SHARED_NETWORK)
|
|
return spec['path']
|
|
except Exception as e: # pragma: no cover - defensive
|
|
logger.warning('compose env overlay refresh failed for %s: %s', project_path, e)
|
|
return None
|
|
|
|
@classmethod
|
|
def _remove_override(cls, project_path):
|
|
try:
|
|
path = cls.override_path(project_path)
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
except OSError as e: # pragma: no cover - defensive
|
|
logger.debug('could not remove stale compose override: %s', e)
|