mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-02 18:17:11 +09:00
551 lines
21 KiB
Python
551 lines
21 KiB
Python
"""Service layer for polymorphic shared resources (tags + variable groups).
|
|
|
|
A thin, stateless facade over the models in
|
|
``app.models.shared_resource``. It is purely additive: nothing here reads or
|
|
writes the existing per-resource env-var tables. Resources are addressed by
|
|
``(resource_type, resource_id)`` where ``resource_id`` is coerced to a string so
|
|
both int-keyed (apps, servers) and string-keyed (container names) resources work.
|
|
|
|
Override / merge rule for :meth:`resolve_variables`
|
|
---------------------------------------------------
|
|
A resource may have several groups attached. The effective variable set is built
|
|
by iterating the attached groups **in attachment order** (oldest attachment
|
|
first) and writing each group's variables into a dict keyed by variable key.
|
|
Because a later write overwrites an earlier one, **the most recently attached
|
|
group wins** on key collisions ("last attachment wins"). Each resolved entry
|
|
records the ``group_id``/``group_name`` it came from so callers can show
|
|
provenance.
|
|
"""
|
|
import re
|
|
|
|
from app import db
|
|
from app.utils.actor import current_actor_id as _current_user_id
|
|
from app.models.shared_resource import (
|
|
ResourceTag,
|
|
SharedVariable,
|
|
SharedVariableGroup,
|
|
SharedVariableGroupAttachment,
|
|
)
|
|
|
|
# Sentinel: distinguish "leave target_service unchanged" from "clear to all (None)".
|
|
_UNSET = object()
|
|
|
|
|
|
def _audit(action, **kwargs):
|
|
"""Best-effort audit log for a shared-resource mutation.
|
|
|
|
Audit logging must never break the underlying write, so any failure here
|
|
(missing request context, telemetry hiccup, etc.) is swallowed. We reuse
|
|
the generic ``resource.*`` actions plus a ``feature: 'shared_resource'``
|
|
marker in ``details`` so these rows are filterable without a new action
|
|
constant or DB table.
|
|
"""
|
|
try:
|
|
from app.services.audit_service import AuditService
|
|
details = kwargs.pop('details', None) or {}
|
|
details.setdefault('feature', 'shared_resource')
|
|
AuditService.log(action, details=details, **kwargs)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class SharedResourceService:
|
|
"""Static facade for tags and shared variable groups."""
|
|
|
|
# Supported polymorphic resource types. Kept as a constant so the API and
|
|
# the frontend can advertise the same catalog.
|
|
RESOURCE_TYPES = (
|
|
'application',
|
|
'database',
|
|
'service',
|
|
'wordpress',
|
|
'server',
|
|
)
|
|
|
|
# ------------------------------------------------------------------ tags
|
|
|
|
@staticmethod
|
|
def _rid(resource_id):
|
|
"""Normalize a resource id to the string form stored in the DB."""
|
|
return str(resource_id)
|
|
|
|
@staticmethod
|
|
def add_tag(resource_type, resource_id, tag):
|
|
"""Attach a tag to a resource. Idempotent — returns the existing or new row."""
|
|
tag = (tag or '').strip()
|
|
if not tag:
|
|
raise ValueError('tag is required')
|
|
rid = SharedResourceService._rid(resource_id)
|
|
|
|
existing = ResourceTag.query.filter_by(
|
|
resource_type=resource_type, resource_id=rid, tag=tag
|
|
).first()
|
|
if existing:
|
|
return existing
|
|
|
|
row = ResourceTag(resource_type=resource_type, resource_id=rid, tag=tag)
|
|
db.session.add(row)
|
|
try:
|
|
db.session.commit()
|
|
except Exception:
|
|
# Lost a race with a concurrent insert — fall back to the winner.
|
|
db.session.rollback()
|
|
return ResourceTag.query.filter_by(
|
|
resource_type=resource_type, resource_id=rid, tag=tag
|
|
).first()
|
|
_audit(
|
|
'resource.update',
|
|
user_id=_current_user_id(),
|
|
target_type=resource_type,
|
|
details={'op': 'tag.add', 'resource_id': rid, 'tag': tag},
|
|
)
|
|
return row
|
|
|
|
@staticmethod
|
|
def remove_tag(resource_type, resource_id, tag):
|
|
"""Detach a tag from a resource. Returns True if a row was deleted."""
|
|
rid = SharedResourceService._rid(resource_id)
|
|
row = ResourceTag.query.filter_by(
|
|
resource_type=resource_type, resource_id=rid, tag=(tag or '').strip()
|
|
).first()
|
|
if not row:
|
|
return False
|
|
db.session.delete(row)
|
|
db.session.commit()
|
|
_audit(
|
|
'resource.update',
|
|
user_id=_current_user_id(),
|
|
target_type=resource_type,
|
|
details={'op': 'tag.remove', 'resource_id': rid,
|
|
'tag': (tag or '').strip()},
|
|
)
|
|
return True
|
|
|
|
@staticmethod
|
|
def list_tags(resource_type, resource_id):
|
|
"""List all tags on a resource (ordered by tag name)."""
|
|
rid = SharedResourceService._rid(resource_id)
|
|
return ResourceTag.query.filter_by(
|
|
resource_type=resource_type, resource_id=rid
|
|
).order_by(ResourceTag.tag.asc()).all()
|
|
|
|
@staticmethod
|
|
def list_resources_by_tag(tag, resource_type=None):
|
|
"""List every resource carrying a given tag (optionally one type)."""
|
|
query = ResourceTag.query.filter_by(tag=(tag or '').strip())
|
|
if resource_type:
|
|
query = query.filter_by(resource_type=resource_type)
|
|
return query.order_by(
|
|
ResourceTag.resource_type.asc(), ResourceTag.resource_id.asc()
|
|
).all()
|
|
|
|
# --------------------------------------------------------------- groups
|
|
|
|
@staticmethod
|
|
def create_group(scope_type, scope_id, name, description=None):
|
|
"""Create a new shared variable group."""
|
|
if scope_type not in SharedVariableGroup.VALID_SCOPES:
|
|
raise ValueError(f'invalid scope_type: {scope_type}')
|
|
name = (name or '').strip()
|
|
if not name:
|
|
raise ValueError('name is required')
|
|
|
|
group = SharedVariableGroup(
|
|
scope_type=scope_type,
|
|
scope_id=SharedResourceService._rid(scope_id),
|
|
name=name,
|
|
description=(description or None),
|
|
)
|
|
db.session.add(group)
|
|
db.session.commit()
|
|
_audit(
|
|
'resource.create',
|
|
user_id=_current_user_id(),
|
|
target_type='shared_variable_group',
|
|
target_id=group.id,
|
|
details={'op': 'group.create', 'scope_type': scope_type,
|
|
'scope_id': SharedResourceService._rid(scope_id),
|
|
'name': group.name},
|
|
)
|
|
return group
|
|
|
|
@staticmethod
|
|
def get_group(group_id):
|
|
return SharedVariableGroup.query.get(group_id)
|
|
|
|
@staticmethod
|
|
def list_groups(scope_type=None, scope_id=None):
|
|
"""List groups, optionally filtered by scope."""
|
|
query = SharedVariableGroup.query
|
|
if scope_type:
|
|
query = query.filter_by(scope_type=scope_type)
|
|
if scope_id is not None:
|
|
query = query.filter_by(scope_id=SharedResourceService._rid(scope_id))
|
|
return query.order_by(SharedVariableGroup.name.asc()).all()
|
|
|
|
@staticmethod
|
|
def update_group(group_id, name=None, description=None):
|
|
group = SharedVariableGroup.query.get(group_id)
|
|
if not group:
|
|
return None
|
|
if name is not None:
|
|
name = name.strip()
|
|
if not name:
|
|
raise ValueError('name cannot be empty')
|
|
group.name = name
|
|
if description is not None:
|
|
group.description = description or None
|
|
db.session.commit()
|
|
_audit(
|
|
'resource.update',
|
|
user_id=_current_user_id(),
|
|
target_type='shared_variable_group',
|
|
target_id=group.id,
|
|
details={'op': 'group.update', 'name': group.name},
|
|
)
|
|
return group
|
|
|
|
@staticmethod
|
|
def delete_group(group_id):
|
|
"""Delete a group (cascades to its variables and attachments)."""
|
|
group = SharedVariableGroup.query.get(group_id)
|
|
if not group:
|
|
return False
|
|
name = group.name
|
|
db.session.delete(group)
|
|
db.session.commit()
|
|
_audit(
|
|
'resource.delete',
|
|
user_id=_current_user_id(),
|
|
target_type='shared_variable_group',
|
|
target_id=group_id,
|
|
details={'op': 'group.delete', 'name': name},
|
|
)
|
|
return True
|
|
|
|
# --------------------------------------------- variables within a group
|
|
|
|
@staticmethod
|
|
def set_variable(group_id, key, value, is_secret=False, target_service=_UNSET):
|
|
"""Create or update a variable in a group (upsert by key).
|
|
|
|
``target_service`` scopes the var to one compose service (None = all).
|
|
Left unset on update, the existing target is preserved.
|
|
"""
|
|
group = SharedVariableGroup.query.get(group_id)
|
|
if not group:
|
|
return None
|
|
key = (key or '').strip()
|
|
if not key:
|
|
raise ValueError('key is required')
|
|
|
|
norm_target = None if target_service in ('', _UNSET) else target_service
|
|
var = SharedVariable.query.filter_by(group_id=group_id, key=key).first()
|
|
created = var is None
|
|
if var is None:
|
|
var = SharedVariable(group_id=group_id, key=key, is_secret=bool(is_secret),
|
|
target_service=norm_target)
|
|
var.value = value if value is not None else ''
|
|
db.session.add(var)
|
|
else:
|
|
var.value = value if value is not None else ''
|
|
var.is_secret = bool(is_secret)
|
|
if target_service is not _UNSET:
|
|
var.target_service = norm_target
|
|
db.session.commit()
|
|
_audit(
|
|
'resource.update',
|
|
user_id=_current_user_id(),
|
|
target_type='shared_variable_group',
|
|
target_id=group_id,
|
|
details={'op': 'variable.create' if created else 'variable.update',
|
|
'key': key, 'is_secret': bool(is_secret)},
|
|
)
|
|
return var
|
|
|
|
@staticmethod
|
|
def update_variable(variable_id, value=None, is_secret=None, target_service=_UNSET):
|
|
var = SharedVariable.query.get(variable_id)
|
|
if not var:
|
|
return None
|
|
if value is not None:
|
|
var.value = value
|
|
if is_secret is not None:
|
|
var.is_secret = bool(is_secret)
|
|
if target_service is not _UNSET:
|
|
var.target_service = None if target_service in ('', None) else target_service
|
|
db.session.commit()
|
|
_audit(
|
|
'resource.update',
|
|
user_id=_current_user_id(),
|
|
target_type='shared_variable_group',
|
|
target_id=var.group_id,
|
|
details={'op': 'variable.update', 'key': var.key,
|
|
'is_secret': bool(var.is_secret)},
|
|
)
|
|
return var
|
|
|
|
@staticmethod
|
|
def delete_variable(variable_id):
|
|
var = SharedVariable.query.get(variable_id)
|
|
if not var:
|
|
return False
|
|
group_id, key = var.group_id, var.key
|
|
db.session.delete(var)
|
|
db.session.commit()
|
|
_audit(
|
|
'resource.update',
|
|
user_id=_current_user_id(),
|
|
target_type='shared_variable_group',
|
|
target_id=group_id,
|
|
details={'op': 'variable.delete', 'key': key},
|
|
)
|
|
return True
|
|
|
|
@staticmethod
|
|
def list_variables(group_id):
|
|
return SharedVariable.query.filter_by(group_id=group_id).order_by(
|
|
SharedVariable.key.asc()
|
|
).all()
|
|
|
|
# ---------------------------------------------------------- attachments
|
|
|
|
@staticmethod
|
|
def attach_group(group_id, resource_type, resource_id):
|
|
"""Attach a group to a resource. Idempotent."""
|
|
group = SharedVariableGroup.query.get(group_id)
|
|
if not group:
|
|
return None
|
|
rid = SharedResourceService._rid(resource_id)
|
|
|
|
existing = SharedVariableGroupAttachment.query.filter_by(
|
|
group_id=group_id, resource_type=resource_type, resource_id=rid
|
|
).first()
|
|
if existing:
|
|
return existing
|
|
|
|
att = SharedVariableGroupAttachment(
|
|
group_id=group_id, resource_type=resource_type, resource_id=rid
|
|
)
|
|
db.session.add(att)
|
|
try:
|
|
db.session.commit()
|
|
except Exception:
|
|
db.session.rollback()
|
|
return SharedVariableGroupAttachment.query.filter_by(
|
|
group_id=group_id, resource_type=resource_type, resource_id=rid
|
|
).first()
|
|
_audit(
|
|
'resource.update',
|
|
user_id=_current_user_id(),
|
|
target_type=resource_type,
|
|
details={'op': 'group.attach', 'resource_id': rid,
|
|
'group_id': group_id},
|
|
)
|
|
return att
|
|
|
|
@staticmethod
|
|
def detach_group(group_id, resource_type, resource_id):
|
|
"""Detach a group from a resource. Returns True if a row was deleted."""
|
|
rid = SharedResourceService._rid(resource_id)
|
|
att = SharedVariableGroupAttachment.query.filter_by(
|
|
group_id=group_id, resource_type=resource_type, resource_id=rid
|
|
).first()
|
|
if not att:
|
|
return False
|
|
db.session.delete(att)
|
|
db.session.commit()
|
|
_audit(
|
|
'resource.update',
|
|
user_id=_current_user_id(),
|
|
target_type=resource_type,
|
|
details={'op': 'group.detach', 'resource_id': rid,
|
|
'group_id': group_id},
|
|
)
|
|
return True
|
|
|
|
@staticmethod
|
|
def list_attached_groups(resource_type, resource_id):
|
|
"""Return the groups attached to a resource, in attachment order."""
|
|
rid = SharedResourceService._rid(resource_id)
|
|
attachments = SharedVariableGroupAttachment.query.filter_by(
|
|
resource_type=resource_type, resource_id=rid
|
|
).order_by(SharedVariableGroupAttachment.created_at.asc(),
|
|
SharedVariableGroupAttachment.id.asc()).all()
|
|
|
|
groups = []
|
|
for att in attachments:
|
|
group = SharedVariableGroup.query.get(att.group_id)
|
|
if group:
|
|
groups.append(group)
|
|
return groups
|
|
|
|
# ------------------------------------------------------------- resolve
|
|
|
|
@staticmethod
|
|
def resolve_variables(resource_type, resource_id, mask_secrets=True):
|
|
"""Merge variables from every attached group into one effective set.
|
|
|
|
Merge rule: groups are applied in attachment order (oldest first), so a
|
|
key defined in a later-attached group overrides an earlier one
|
|
(**last attachment wins**). Each entry records its source group.
|
|
"""
|
|
groups = SharedResourceService.list_attached_groups(resource_type, resource_id)
|
|
|
|
resolved = {}
|
|
for group in groups:
|
|
for var in group.variables:
|
|
resolved[var.key] = {
|
|
'key': var.key,
|
|
'value': (SharedVariable.to_dict(var, mask_secrets=mask_secrets)
|
|
['value']),
|
|
'is_secret': var.is_secret,
|
|
'group_id': group.id,
|
|
'group_name': group.name,
|
|
}
|
|
|
|
return [resolved[k] for k in sorted(resolved.keys())]
|
|
|
|
# ------------------------------------------------ hierarchical resolve
|
|
|
|
# Precedence ladder, lowest → highest. The most specific scope wins on a
|
|
# key collision, with directly-attached groups overriding every inherited
|
|
# scope. ``resource`` is reserved for a resource's own direct variables and
|
|
# always wins last.
|
|
SCOPE_PRECEDENCE = ('workspace', 'project', 'environment', 'direct', 'resource')
|
|
|
|
# Matches ``{{group.KEY}}`` references for cross-variable interpolation.
|
|
_REF_RE = re.compile(r'\{\{\s*group\.([A-Za-z0-9_.\-]+)\s*\}\}')
|
|
|
|
@staticmethod
|
|
def _interpolate(value, plaintext_by_key, _depth=0):
|
|
"""Resolve ``{{group.KEY}}`` references within a plaintext value.
|
|
|
|
References point at other resolved keys (the plaintext map). Unknown
|
|
references are left verbatim. Recursion is depth-bounded so a cycle
|
|
(A→B→A) degrades to the raw token instead of looping forever. This only
|
|
runs on already-decrypted plaintext, so secrets are interpolated before
|
|
masking — never the mask glyphs.
|
|
"""
|
|
if not isinstance(value, str) or '{{' not in value or _depth > 8:
|
|
return value
|
|
|
|
def _sub(match):
|
|
ref_key = match.group(1)
|
|
if ref_key not in plaintext_by_key:
|
|
return match.group(0) # leave unknown refs untouched
|
|
return SharedResourceService._interpolate(
|
|
plaintext_by_key[ref_key], plaintext_by_key, _depth + 1
|
|
)
|
|
|
|
return SharedResourceService._REF_RE.sub(_sub, value)
|
|
|
|
@staticmethod
|
|
def resolve_hierarchical_from_layers(layers, direct_vars=None,
|
|
mask_secrets=True, interpolate=True,
|
|
service=None):
|
|
"""Pure merge over pre-fetched layers — the unit-testable core.
|
|
|
|
``layers`` is an ordered iterable of ``(source_scope, groups)`` tuples
|
|
applied lowest → highest precedence; each ``groups`` is an iterable of
|
|
:class:`SharedVariableGroup`. ``direct_vars`` is an optional iterable of
|
|
:class:`SharedVariable` representing the resource's own direct variables,
|
|
which always take top precedence (``source_scope='resource'``).
|
|
|
|
A later layer overrides an earlier one on key collisions ("most specific
|
|
wins"). Each resolved entry records the ``source_scope`` it won at plus
|
|
its source group. Secrets are masked in the returned ``value`` when
|
|
``mask_secrets`` is set, but ``{{group.KEY}}`` interpolation runs on the
|
|
decrypted plaintext first so references resolve correctly regardless of
|
|
masking.
|
|
"""
|
|
# First pass (lowest → highest): accumulate the winning entry per key
|
|
# along with its decrypted plaintext, so interpolation can see the
|
|
# final effective set.
|
|
resolved = {}
|
|
plaintext_by_key = {}
|
|
|
|
def _targets(var):
|
|
"""A var applies when it targets all services (NULL) or, if a service
|
|
is requested, that specific service."""
|
|
tgt = getattr(var, 'target_service', None)
|
|
if service is None or tgt in (None, ''):
|
|
return True
|
|
return tgt == service
|
|
|
|
def _apply(source_scope, var, group):
|
|
if not _targets(var):
|
|
return
|
|
plaintext = var.value # decrypted
|
|
resolved[var.key] = {
|
|
'key': var.key,
|
|
'plaintext': plaintext,
|
|
'is_secret': bool(var.is_secret),
|
|
'target_service': getattr(var, 'target_service', None),
|
|
'group_id': group.id if group is not None else None,
|
|
'group_name': group.name if group is not None else None,
|
|
'source_scope': source_scope,
|
|
}
|
|
plaintext_by_key[var.key] = plaintext
|
|
|
|
for source_scope, groups in layers:
|
|
for group in (groups or []):
|
|
for var in group.variables:
|
|
_apply(source_scope, var, group)
|
|
|
|
for var in (direct_vars or []):
|
|
_apply('resource', var, None)
|
|
|
|
# Second pass: interpolate references against the final plaintext map,
|
|
# then mask. Two passes keep references stable even when the referenced
|
|
# value is overridden by a more specific scope.
|
|
out = []
|
|
for key in sorted(resolved.keys()):
|
|
entry = resolved[key]
|
|
plaintext = entry.pop('plaintext')
|
|
if interpolate:
|
|
plaintext = SharedResourceService._interpolate(
|
|
plaintext, plaintext_by_key
|
|
)
|
|
entry['value'] = ('••••••••' if (mask_secrets and entry['is_secret'])
|
|
else plaintext)
|
|
out.append(entry)
|
|
return out
|
|
|
|
@staticmethod
|
|
def resolve_hierarchical(resource_type, resource_id, context=None,
|
|
mask_secrets=True, interpolate=True, service=None):
|
|
"""DB-backed hierarchical resolution for a resource.
|
|
|
|
``context`` may carry ``workspace_id``, ``project_id`` and
|
|
``environment_id``. Groups scoped to each (by ``scope_type``/``scope_id``)
|
|
form the inherited layers; the resource's directly-attached groups form
|
|
the ``direct`` layer on top. Precedence, lowest → highest:
|
|
|
|
workspace < project < environment < direct attachments
|
|
|
|
See :meth:`resolve_hierarchical_from_layers` for the pure merge.
|
|
"""
|
|
context = context or {}
|
|
layers = []
|
|
|
|
def _scoped(scope_type, scope_id):
|
|
if scope_id in (None, ''):
|
|
return []
|
|
return SharedResourceService.list_groups(
|
|
scope_type=scope_type, scope_id=scope_id
|
|
)
|
|
|
|
layers.append(('workspace', _scoped('workspace', context.get('workspace_id'))))
|
|
layers.append(('project', _scoped('project', context.get('project_id'))))
|
|
layers.append(('environment',
|
|
_scoped('environment', context.get('environment_id'))))
|
|
layers.append(('direct',
|
|
SharedResourceService.list_attached_groups(
|
|
resource_type, resource_id)))
|
|
|
|
return SharedResourceService.resolve_hierarchical_from_layers(
|
|
layers, direct_vars=None, mask_secrets=mask_secrets,
|
|
interpolate=interpolate, service=service,
|
|
)
|