Files
ServerKit/backend/app/services/env_reference_service.py
T
Juan DenisandClaude Opus 5 5f8a3ef204 feat(recycle-bin): applications are soft-deletable, and a delete stops keeping data hostage
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>
2026-08-12 20:26:04 -04:00

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'