mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-03 10:36:45 +09:00
A variable can now land on one compose service instead of all of them. Both EnvironmentVariable and SharedVariable gain an optional target_service (NULL = all services), threaded through the shared resolver as an inclusion filter (a var applies to a service when it targets all OR that service). EnvService.get_effective_env_for_services(app, services) computes each service's merged env (all-services vars + that-service vars, local overriding shared), and ComposeEnvService builds the override's per-service environment from it — so a var targeted at "db" only appears under the db service, untargeted vars appear everywhere. The single-container docker-run path is unchanged (one container gets the full effective env regardless of target). API: env-var create/update and shared-variable create/update accept target_service; GET /apps/<id>/compose-services lists a compose app's services. UI: the Environment tab shows an "Applies to" selector (gated to compose apps via the service list) with a per-row target chip; the shared variable editor gains a target-service field. Migration 036 adds the two columns; single Alembic head; chain applies from empty. Proving tests cover per-service distribution + the resolver helper. Full backend suite: 949 passed, 14 skipped; frontend builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
297 lines
11 KiB
Python
297 lines
11 KiB
Python
"""Polymorphic shared resources API — tags + shared variable groups.
|
|
|
|
Mounted at ``/api/v1/shared``. Everything here is a JWT-protected facade over
|
|
:class:`~app.services.shared_resource_service.SharedResourceService`. Secret
|
|
variable values are always masked in responses via ``to_dict(mask_secrets=True)``
|
|
plus a defense-in-depth pass through :func:`mask_sensitive`.
|
|
"""
|
|
from flask import Blueprint, jsonify, request
|
|
from flask_jwt_extended import jwt_required
|
|
|
|
from app.services.shared_resource_service import SharedResourceService
|
|
from app.models.shared_resource import SharedVariableGroup
|
|
from app.utils.sensitive_data_filter import mask_sensitive
|
|
|
|
shared_resources_bp = Blueprint('shared_resources', __name__)
|
|
|
|
|
|
def _bad(msg, code=400):
|
|
return jsonify({'error': msg}), code
|
|
|
|
|
|
def _require(data, *keys):
|
|
"""Return the first missing key name, or None if all present/truthy."""
|
|
for k in keys:
|
|
if data.get(k) in (None, ''):
|
|
return k
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------- metadata
|
|
|
|
@shared_resources_bp.route('/resource-types', methods=['GET'])
|
|
@jwt_required()
|
|
def resource_types():
|
|
"""The catalog of supported polymorphic resource types."""
|
|
return jsonify({'resource_types': list(SharedResourceService.RESOURCE_TYPES)})
|
|
|
|
|
|
# -------------------------------------------------------------------- tags
|
|
|
|
@shared_resources_bp.route('/tags', methods=['GET'])
|
|
@jwt_required()
|
|
def list_tags():
|
|
"""List tags for a resource, or resources for a tag.
|
|
|
|
``?resource_type=&resource_id=`` → tags on that resource.
|
|
``?tag=`` (optionally with ``resource_type``) → resources carrying the tag.
|
|
"""
|
|
tag = request.args.get('tag')
|
|
resource_type = request.args.get('resource_type')
|
|
resource_id = request.args.get('resource_id')
|
|
|
|
if tag:
|
|
rows = SharedResourceService.list_resources_by_tag(tag, resource_type)
|
|
return jsonify({'resources': [r.to_dict() for r in rows]})
|
|
|
|
if not resource_type or resource_id in (None, ''):
|
|
return _bad('resource_type and resource_id (or tag) are required')
|
|
|
|
rows = SharedResourceService.list_tags(resource_type, resource_id)
|
|
return jsonify({'tags': [r.to_dict() for r in rows]})
|
|
|
|
|
|
@shared_resources_bp.route('/tags', methods=['POST'])
|
|
@jwt_required()
|
|
def add_tag():
|
|
data = request.get_json() or {}
|
|
missing = _require(data, 'resource_type', 'resource_id', 'tag')
|
|
if missing:
|
|
return _bad(f'{missing} is required')
|
|
try:
|
|
row = SharedResourceService.add_tag(
|
|
data['resource_type'], data['resource_id'], data['tag']
|
|
)
|
|
except ValueError as e:
|
|
return _bad(str(e))
|
|
return jsonify(row.to_dict()), 201
|
|
|
|
|
|
@shared_resources_bp.route('/tags', methods=['DELETE'])
|
|
@jwt_required()
|
|
def remove_tag():
|
|
# Accept either body or query params for convenience.
|
|
data = request.get_json(silent=True) or {}
|
|
resource_type = data.get('resource_type') or request.args.get('resource_type')
|
|
resource_id = data.get('resource_id')
|
|
if resource_id is None:
|
|
resource_id = request.args.get('resource_id')
|
|
tag = data.get('tag') or request.args.get('tag')
|
|
|
|
if not resource_type or resource_id in (None, '') or not tag:
|
|
return _bad('resource_type, resource_id and tag are required')
|
|
|
|
removed = SharedResourceService.remove_tag(resource_type, resource_id, tag)
|
|
return jsonify({'removed': removed})
|
|
|
|
|
|
# --------------------------------------------------------- variable groups
|
|
|
|
@shared_resources_bp.route('/variable-groups', methods=['GET'])
|
|
@jwt_required()
|
|
def list_groups():
|
|
groups = SharedResourceService.list_groups(
|
|
scope_type=request.args.get('scope_type'),
|
|
scope_id=request.args.get('scope_id'),
|
|
)
|
|
return jsonify({'groups': [g.to_dict() for g in groups]})
|
|
|
|
|
|
@shared_resources_bp.route('/variable-groups', methods=['POST'])
|
|
@jwt_required()
|
|
def create_group():
|
|
data = request.get_json() or {}
|
|
missing = _require(data, 'scope_type', 'scope_id', 'name')
|
|
if missing:
|
|
return _bad(f'{missing} is required')
|
|
try:
|
|
group = SharedResourceService.create_group(
|
|
data['scope_type'], data['scope_id'], data['name'],
|
|
data.get('description'),
|
|
)
|
|
except ValueError as e:
|
|
return _bad(str(e))
|
|
return jsonify(group.to_dict(include_variables=True)), 201
|
|
|
|
|
|
@shared_resources_bp.route('/variable-groups/<int:group_id>', methods=['GET'])
|
|
@jwt_required()
|
|
def get_group(group_id):
|
|
group = SharedResourceService.get_group(group_id)
|
|
if not group:
|
|
return _bad('group not found', 404)
|
|
payload = group.to_dict(include_variables=True, mask_secrets=True)
|
|
payload['attachments'] = [a.to_dict() for a in group.attachments]
|
|
return jsonify(mask_sensitive(payload))
|
|
|
|
|
|
@shared_resources_bp.route('/variable-groups/<int:group_id>', methods=['PUT'])
|
|
@jwt_required()
|
|
def update_group(group_id):
|
|
data = request.get_json() or {}
|
|
try:
|
|
group = SharedResourceService.update_group(
|
|
group_id, name=data.get('name'), description=data.get('description')
|
|
)
|
|
except ValueError as e:
|
|
return _bad(str(e))
|
|
if not group:
|
|
return _bad('group not found', 404)
|
|
return jsonify(group.to_dict(include_variables=True))
|
|
|
|
|
|
@shared_resources_bp.route('/variable-groups/<int:group_id>', methods=['DELETE'])
|
|
@jwt_required()
|
|
def delete_group(group_id):
|
|
if not SharedResourceService.delete_group(group_id):
|
|
return _bad('group not found', 404)
|
|
return jsonify({'message': 'group deleted'})
|
|
|
|
|
|
# --------------------------------------------- variables within a group
|
|
|
|
@shared_resources_bp.route('/variable-groups/<int:group_id>/variables', methods=['POST'])
|
|
@jwt_required()
|
|
def add_variable(group_id):
|
|
data = request.get_json() or {}
|
|
if data.get('key') in (None, ''):
|
|
return _bad('key is required')
|
|
try:
|
|
var = SharedResourceService.set_variable(
|
|
group_id, data['key'], data.get('value', ''),
|
|
is_secret=bool(data.get('is_secret', False)),
|
|
target_service=data.get('target_service') if 'target_service' in data else None,
|
|
)
|
|
except ValueError as e:
|
|
return _bad(str(e))
|
|
if not var:
|
|
return _bad('group not found', 404)
|
|
return jsonify(var.to_dict(mask_secrets=True)), 201
|
|
|
|
|
|
@shared_resources_bp.route(
|
|
'/variable-groups/<int:group_id>/variables/<int:variable_id>', methods=['PUT'])
|
|
@jwt_required()
|
|
def update_variable(group_id, variable_id):
|
|
data = request.get_json() or {}
|
|
_kwargs = {'value': data.get('value'), 'is_secret': data.get('is_secret')}
|
|
if 'target_service' in data:
|
|
# Only forward when the client sent it, so it's preserved otherwise.
|
|
_kwargs['target_service'] = data.get('target_service')
|
|
var = SharedResourceService.update_variable(variable_id, **_kwargs)
|
|
if not var or var.group_id != group_id:
|
|
return _bad('variable not found', 404)
|
|
return jsonify(var.to_dict(mask_secrets=True))
|
|
|
|
|
|
@shared_resources_bp.route(
|
|
'/variable-groups/<int:group_id>/variables/<int:variable_id>', methods=['DELETE'])
|
|
@jwt_required()
|
|
def delete_variable(group_id, variable_id):
|
|
from app.models.shared_resource import SharedVariable
|
|
var = SharedVariable.query.get(variable_id)
|
|
if not var or var.group_id != group_id:
|
|
return _bad('variable not found', 404)
|
|
SharedResourceService.delete_variable(variable_id)
|
|
return jsonify({'message': 'variable deleted'})
|
|
|
|
|
|
# --------------------------------------------------------------- attach
|
|
|
|
@shared_resources_bp.route('/variable-groups/<int:group_id>/attach', methods=['POST'])
|
|
@jwt_required()
|
|
def attach_group(group_id):
|
|
data = request.get_json() or {}
|
|
missing = _require(data, 'resource_type', 'resource_id')
|
|
if missing:
|
|
return _bad(f'{missing} is required')
|
|
att = SharedResourceService.attach_group(
|
|
group_id, data['resource_type'], data['resource_id']
|
|
)
|
|
if not att:
|
|
return _bad('group not found', 404)
|
|
return jsonify(att.to_dict()), 201
|
|
|
|
|
|
@shared_resources_bp.route('/variable-groups/<int:group_id>/detach', methods=['POST'])
|
|
@jwt_required()
|
|
def detach_group(group_id):
|
|
data = request.get_json() or {}
|
|
missing = _require(data, 'resource_type', 'resource_id')
|
|
if missing:
|
|
return _bad(f'{missing} is required')
|
|
removed = SharedResourceService.detach_group(
|
|
group_id, data['resource_type'], data['resource_id']
|
|
)
|
|
return jsonify({'detached': removed})
|
|
|
|
|
|
# -------------------------------------------------------------- resolved
|
|
|
|
@shared_resources_bp.route('/resolved', methods=['GET'])
|
|
@jwt_required()
|
|
def resolved():
|
|
"""Effective merged variables for a resource (secrets masked)."""
|
|
resource_type = request.args.get('resource_type')
|
|
resource_id = request.args.get('resource_id')
|
|
if not resource_type or resource_id in (None, ''):
|
|
return _bad('resource_type and resource_id are required')
|
|
|
|
variables = SharedResourceService.resolve_variables(
|
|
resource_type, resource_id, mask_secrets=True
|
|
)
|
|
groups = SharedResourceService.list_attached_groups(resource_type, resource_id)
|
|
payload = {
|
|
'resource_type': resource_type,
|
|
'resource_id': str(resource_id),
|
|
'variables': variables,
|
|
'groups': [g.to_dict() for g in groups],
|
|
}
|
|
return jsonify(mask_sensitive(payload))
|
|
|
|
|
|
@shared_resources_bp.route('/resolved/hierarchical', methods=['GET'])
|
|
@jwt_required()
|
|
def resolved_hierarchical():
|
|
"""Hierarchical effective variables for a resource (secrets masked).
|
|
|
|
Merges scope-inherited groups with the resource's directly-attached groups.
|
|
Precedence, lowest → highest:
|
|
|
|
workspace < project < environment < direct attachments
|
|
|
|
Each returned variable carries a ``source_scope`` provenance marker. Pass the
|
|
scope ids via ``?workspace_id=&project_id=&environment_id=``; any omitted
|
|
scope simply contributes no layer.
|
|
"""
|
|
resource_type = request.args.get('resource_type')
|
|
resource_id = request.args.get('resource_id')
|
|
if not resource_type or resource_id in (None, ''):
|
|
return _bad('resource_type and resource_id are required')
|
|
|
|
context = {
|
|
'workspace_id': request.args.get('workspace_id'),
|
|
'project_id': request.args.get('project_id'),
|
|
'environment_id': request.args.get('environment_id'),
|
|
}
|
|
variables = SharedResourceService.resolve_hierarchical(
|
|
resource_type, resource_id, context=context, mask_secrets=True
|
|
)
|
|
payload = {
|
|
'resource_type': resource_type,
|
|
'resource_id': str(resource_id),
|
|
'context': {k: v for k, v in context.items() if v not in (None, '')},
|
|
'variables': variables,
|
|
}
|
|
return jsonify(mask_sensitive(payload))
|