mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-09 13:35:12 +09:00
Plan 70. Domain went through this and TEN read paths were wrong, four with
effects outside the panel. Application has 211 call sites across 68 files and
drives more -- container lifecycles, nginx, backups, registry pulls, the OS
crontab -- so it was audited in full first (docs/plans/70_...md). 180 sites are
now query_active(); the 35 left unfiltered are each commented in place.
**The delete / purge split is what makes the tombstone honest.** delete_app used
to run compose_down(volumes=True) and rmtree(). Keeping a tombstone AND doing
that would put a Restore button next to a row whose data the delete had already
destroyed -- the one promise a recycle bin cannot break. So:
DELETE containers down (VOLUMES KEPT), vhost removed, firewall closed,
cron suspended, row tombstoned. All reversible.
RESTORE vhost rewritten, firewall reopened, cron resumed. Containers left
STOPPED -- "put it back" must not silently start processes or open
ports, the same reasoning that stops domain restore re-issuing certs.
PURGE volumes removed, uploaded source deleted, cron detached.
register() gained on_purge for that, wired into BOTH purge paths. And
purge_expired had NO scheduler -- fine when a tombstone was just a row, not fine
when it holds data volumes and a source tree, so a daily builtin job now runs
it. Without that, "kept until purge" would have meant kept forever and delete
would have quietly stopped reclaiming disk.
A side effect worth naming: the old _remove_data_flag protection for database
engines is now the DEFAULT for every app, and the flag is carried into
?purge=true so an explicit skip-the-bin delete still honours it.
**Cron was the blocker with no mechanical answer.** before_delete never fires on
a soft delete, and clear_application is LOSSY -- it nulls the association
without recording it, so nothing could put it back. suspend_for_application
instead disables only the jobs that were CURRENTLY ENABLED and stamps each
suspended_by_app; resume re-enables exactly those. That flag is the whole point:
without it a restore switches back on jobs the user deliberately turned off.
clear_application survives as purge-only, where lossiness is correct.
**The indirect reach (audit 3.1) is the class that grep cannot find.**
ContainerSleepPolicy, BackupPolicy, DeploymentJob and WafPolicy are enumerated
on a SCHEDULE and resolve application_id afterwards, so they never appear in a
search for Application.query. Left alone, a tombstoned app's sleep policy is
swept forever and wake_app restarts the containers the delete tore down. Those
loops test Application.deleted_ids().
Deliberately NOT scoped: reservation checks (port, name, directory) -- a
tombstone must keep holding those or a new app takes them and the restore is
refused; api/git.py's webhook VISIBILITY gate, which falls through to
"panel-wide visible" so hiding a tombstone would WIDEN access; and cron
attribution, which only labels a row.
Verified: 15 new tests, the migration replayed on a fresh DB (a tombstone frees
its private_slug, two live rows still collide), and a full delete -> restore
round trip. The 20 remaining full-suite failures are the known
serverkit-wordpress-not-installed class and are identical on a clean tree.
Two traps caught by testing, both recorded in the plan: a bad import made the
ENTIRE purge cleanup silently no-op while purge() still reported success
(_run_purge_hook swallows by design, so assert on the warning, not just ok), and
private_slug had to lose unique=True at the MODEL level or a models-built
database burns the slug regardless of the migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
156 lines
6.8 KiB
Python
156 lines
6.8 KiB
Python
"""Resolve manifest env references at injection time (plan 17, Phase 3).
|
|
|
|
`fromSecret` -> a vault secret's value; `fromService` -> a sibling service's
|
|
connection property. The resolved value never lands in the env-var row — it is
|
|
computed here whenever the effective env is built, so rotating a vault secret or
|
|
changing a db password propagates on the next deploy/restart.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any, Dict, Optional, Tuple
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# fromService properties by kind
|
|
_DB_PROPERTIES = ('connectionString', 'host', 'port', 'database', 'username', 'password')
|
|
_APP_PROPERTIES = ('host', 'port', 'url')
|
|
|
|
|
|
class EnvReferenceResolver:
|
|
|
|
@classmethod
|
|
def resolve(cls, app, ref: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]:
|
|
"""Resolve a reference for ``app``. Returns (value, error)."""
|
|
if not isinstance(ref, dict):
|
|
return None, 'invalid reference'
|
|
kind = ref.get('kind')
|
|
if kind == 'secret':
|
|
return cls._resolve_secret(ref.get('secret'))
|
|
if kind == 'service':
|
|
return cls._resolve_service(app, ref.get('service'), ref.get('property'))
|
|
if kind == 'server':
|
|
return cls._resolve_server(app, ref.get('property'))
|
|
return None, f'unknown reference kind: {kind}'
|
|
|
|
# -- fromServer (appliance tier, plan 35) -------------------------------
|
|
|
|
@classmethod
|
|
def _resolve_server(cls, app, prop: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
|
|
"""The service's own advertised identity — the WebRTC/NAT need. Resolves
|
|
against the app's target server, or the panel host when unassigned."""
|
|
prop = prop or 'publicIp'
|
|
server_id = getattr(app, 'server_id', None)
|
|
if server_id:
|
|
from app.models.server import Server
|
|
srv = Server.query.get(server_id)
|
|
if not srv:
|
|
return None, 'target server not found'
|
|
if prop == 'publicIp':
|
|
return (srv.ip_address, None) if srv.ip_address \
|
|
else (None, 'target server has no recorded IP')
|
|
if prop == 'hostname':
|
|
return (getattr(srv, 'hostname', None) or srv.name or None), None
|
|
return None, f'unknown server property `{prop}`'
|
|
# panel host
|
|
if prop == 'publicIp':
|
|
from app.services.site_domain_service import SiteDomainService
|
|
ip = SiteDomainService.server_ip()
|
|
return (ip, None) if ip else (None, 'panel host public IP is not configured')
|
|
if prop == 'hostname':
|
|
import socket
|
|
return socket.gethostname(), None
|
|
return None, f'unknown server property `{prop}`'
|
|
|
|
# -- fromSecret ---------------------------------------------------------
|
|
|
|
@classmethod
|
|
def _resolve_secret(cls, name: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
|
|
if not name:
|
|
return None, 'missing secret name'
|
|
from app.models.secret_vault import Secret
|
|
# a bare name resolves against any vault; a "vault/name" form scopes it
|
|
vault_slug = None
|
|
secret_name = name
|
|
if '/' in name:
|
|
vault_slug, secret_name = name.split('/', 1)
|
|
q = Secret.query.filter_by(name=secret_name)
|
|
if vault_slug:
|
|
from app.models.secret_vault import SecretVault
|
|
vault = SecretVault.query.filter_by(slug=vault_slug).first()
|
|
if not vault:
|
|
return None, f'vault `{vault_slug}` not found'
|
|
q = q.filter_by(vault_id=vault.id)
|
|
secret = q.order_by(Secret.id).first()
|
|
if not secret:
|
|
return None, f'secret `{name}` not found'
|
|
try:
|
|
return secret.value, None
|
|
except Exception as exc: # decryption failure
|
|
return None, f'secret `{name}` unreadable: {exc}'
|
|
|
|
@classmethod
|
|
def secret_exists(cls, name: Optional[str]) -> bool:
|
|
value, error = cls._resolve_secret(name)
|
|
return error is None
|
|
|
|
# -- fromService --------------------------------------------------------
|
|
|
|
@classmethod
|
|
def _resolve_service(cls, app, service_name: Optional[str],
|
|
prop: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
|
|
if not service_name or not prop:
|
|
return None, 'missing service/property'
|
|
project_id = getattr(app, 'project_id', None)
|
|
|
|
# db sibling first (ManagedDatabase by name within the workspace)
|
|
from app.models.managed_database import ManagedDatabase
|
|
managed = ManagedDatabase.query.filter_by(name=service_name).first()
|
|
if managed is not None:
|
|
return cls._db_property(managed, prop)
|
|
|
|
# app sibling (Application by name within the project). Name-keyed, so a
|
|
# tombstone sharing the name would shadow the live app and the wrong
|
|
# app's host/port/secrets would get baked into a live container's env.
|
|
from app.models.application import Application
|
|
sibling = None
|
|
if project_id is not None:
|
|
sibling = Application.query_active().filter_by(project_id=project_id, name=service_name).first()
|
|
if sibling is None:
|
|
sibling = Application.query_active().filter_by(name=service_name).first()
|
|
if sibling is not None:
|
|
return cls._app_property(sibling, service_name, prop)
|
|
|
|
return None, f'service `{service_name}` not found'
|
|
|
|
@classmethod
|
|
def _db_property(cls, managed, prop: str) -> Tuple[Optional[str], Optional[str]]:
|
|
from app.services.managed_database_service import ManagedDatabaseService
|
|
if prop == 'connectionString':
|
|
return ManagedDatabaseService.build_connection_uri(managed, reveal=True), None
|
|
if prop == 'host':
|
|
return managed.host, None
|
|
if prop == 'port':
|
|
return str(managed.effective_port()), None
|
|
if prop == 'database':
|
|
return managed.name, None
|
|
if prop == 'username':
|
|
return managed.admin_username or '', None
|
|
if prop == 'password':
|
|
from app.utils.crypto import decrypt_secret_safe
|
|
return (decrypt_secret_safe(managed.admin_secret_encrypted) or '') \
|
|
if managed.admin_secret_encrypted else '', None
|
|
return None, f'property `{prop}` not valid for a database service'
|
|
|
|
@classmethod
|
|
def _app_property(cls, sibling, service_name: str, prop: str) -> Tuple[Optional[str], Optional[str]]:
|
|
# in-cluster addressing: the service name resolves on the compose/docker network
|
|
host = service_name
|
|
port = sibling.port
|
|
if prop == 'host':
|
|
return host, None
|
|
if prop == 'port':
|
|
return str(port) if port else '', None
|
|
if prop == 'url':
|
|
return f'http://{host}:{port}' if port else f'http://{host}', None
|
|
return None, f'property `{prop}` not valid for an app service'
|