mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-10 05:53:05 +09:00
Store per-registry credentials (Fernet-encrypted) and run `docker login` before pulling a private image — GHCR, Docker Hub, GitLab, ECR, or any generic registry. Public/anonymous pulls are unchanged. - ContainerRegistry model (secret encrypted at rest; to_dict never returns it) + nullable applications.registry_id; migration 042. - container_registry_service: CRUD + login/logout/test_connection (secret piped via stdin, never on argv) + lazy ECR token exchange. - DockerService.pull_image gains a registry= arg (login -> pull -> logout in a try/finally). Wired into the deploy path (_deploy_docker) and the compose path (start_app / apply_image_update): a docker login authenticates both `docker pull` and `docker compose pull`. - API under /api/v1/connections/registries (list=auth, writes=admin); registries join the unified /connections list. - Frontend: Connections hub "Container registry" card (add/test/remove with a provider selector) + a per-app registry picker in Container Ops. - Tests: encrypt/decrypt, stdin-not-argv, login->pull->logout ordering incl. finally-on-failure, deploy + compose auth-before-pull, API CRUD/masking/admin. Deviation from the plan's "single injection point": docker apps run via compose, so login was wired into both the compose and run_container paths; remote-agent compose is a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
109 lines
4.3 KiB
Python
109 lines
4.3 KiB
Python
"""Unified connection registry — one normalized, read-only list of every
|
|
external account ServerKit is connected to (source, DNS, infra, registrar,
|
|
storage, container registries). The individual write paths still live in their
|
|
own blueprints; this is the single source of truth for "what's connected".
|
|
|
|
Container-registry credentials (for private image pulls) are the one write path
|
|
that lives *here*, since a registry is just another external account and has no
|
|
other natural home — see ``container_registry_service``.
|
|
"""
|
|
|
|
from flask import Blueprint, jsonify, request
|
|
|
|
from app.middleware.rbac import admin_required, auth_required, get_current_user
|
|
from app.services.connection_registry import ConnectionRegistry
|
|
from app.services.container_registry_service import ContainerRegistryService
|
|
from app.services.workspace_service import WorkspaceService
|
|
|
|
connections_bp = Blueprint('connections', __name__)
|
|
|
|
|
|
@connections_bp.route('', methods=['GET'])
|
|
@connections_bp.route('/', methods=['GET'])
|
|
@admin_required
|
|
def list_connections():
|
|
"""List every connected external account (secret-free). Admin-only — these are
|
|
server-wide credentials (Cloudflare tokens, cloud keys, …), not personal
|
|
settings, so the whole Connections surface lives under Administration."""
|
|
user = get_current_user()
|
|
return jsonify({'connections': ConnectionRegistry.list_all(
|
|
user_id=user.id if user else None)})
|
|
|
|
|
|
# ── Container registries ─────────────────────────────────────────────────────
|
|
# CRUD + a login round-trip test. Listing is available to any authenticated user
|
|
# (the app-create flow needs it to offer a registry picker); mutations are
|
|
# admin-only, matching every other credential store.
|
|
|
|
def _workspace_id():
|
|
user = get_current_user()
|
|
return WorkspaceService.resolve_workspace_id(
|
|
user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id'))
|
|
|
|
|
|
@connections_bp.route('/registries', methods=['GET'])
|
|
@auth_required()
|
|
def list_registries():
|
|
registries = ContainerRegistryService.list_registries(workspace_id=_workspace_id())
|
|
return jsonify({'registries': [r.to_dict() for r in registries]})
|
|
|
|
|
|
@connections_bp.route('/registries', methods=['POST'])
|
|
@admin_required
|
|
def create_registry():
|
|
data = request.get_json() or {}
|
|
name = (data.get('name') or '').strip()
|
|
if not name:
|
|
return jsonify({'error': 'name is required'}), 400
|
|
user = get_current_user()
|
|
registry = ContainerRegistryService.create(
|
|
name=name,
|
|
provider=data.get('provider') or 'generic',
|
|
registry_url=data.get('registry_url'),
|
|
username=data.get('username'),
|
|
secret=data.get('secret'),
|
|
workspace_id=_workspace_id(),
|
|
created_by=user.id if user else None,
|
|
)
|
|
return jsonify({'registry': registry.to_dict()}), 201
|
|
|
|
|
|
@connections_bp.route('/registries/<int:registry_id>', methods=['PUT'])
|
|
@admin_required
|
|
def update_registry(registry_id):
|
|
registry = ContainerRegistryService.get(registry_id)
|
|
if not registry:
|
|
return jsonify({'error': 'Registry not found'}), 404
|
|
data = request.get_json() or {}
|
|
registry = ContainerRegistryService.update(
|
|
registry,
|
|
name=data.get('name'),
|
|
provider=data.get('provider'),
|
|
registry_url=data.get('registry_url'),
|
|
username=data.get('username'),
|
|
secret=data.get('secret'),
|
|
)
|
|
return jsonify({'registry': registry.to_dict()})
|
|
|
|
|
|
@connections_bp.route('/registries/<int:registry_id>', methods=['DELETE'])
|
|
@admin_required
|
|
def delete_registry(registry_id):
|
|
registry = ContainerRegistryService.get(registry_id)
|
|
if not registry:
|
|
return jsonify({'error': 'Registry not found'}), 404
|
|
ContainerRegistryService.delete(registry)
|
|
return jsonify({'success': True})
|
|
|
|
|
|
@connections_bp.route('/registries/<int:registry_id>/test', methods=['POST'])
|
|
@admin_required
|
|
def test_registry(registry_id):
|
|
registry = ContainerRegistryService.get(registry_id)
|
|
if not registry:
|
|
return jsonify({'error': 'Registry not found'}), 404
|
|
result = ContainerRegistryService.test_connection(registry)
|
|
if result.get('success'):
|
|
return jsonify({'success': True, 'message': f'Logged in to {registry.login_host()}'})
|
|
return jsonify({'success': False, 'error': result.get('error', 'Login failed')}), 400
|