mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-10 05:53:05 +09:00
- Build packs / zero-Dockerfile deploys (C1): new BuildpackService detects the
stack from a cloned repo (Node/Python/Go/PHP/Ruby/Rust/static), produces a
transparent build plan and a generated Dockerfile, and persists the plan +
overrides on the Application. POST /api/v1/buildpacks/{detect,generate}; a
BuildpackPreview shows the detected plan + editable overrides + Dockerfile in
the create wizard and the app Build tab. Builds on the existing build_method
plumbing instead of duplicating it.
- Deployment config snapshots + diff engine (C4): ConfigurationService captures
an immutable, hashed snapshot of resolved config (env keys + masked values,
domains, image/tag, build method, volumes) at every deploy and rollback; new
DeploymentSnapshot model + /api/v1/apps/<id>/snapshots[/diff|/restore]. A
DeploymentTimeline + ConfigDiffModal surface "what changed" and one-click
restore in the app Deploy tab. Secret values are never serialized.
- Template catalog certification (C5 cont.): a whole-catalog validation test
proves all 105 declarative templates conform to the documented schema; fixed
the stale index.json (was 63/105 entries) to list every bundled template.
Migration 030 adds the buildpack columns + deployment_snapshots table. Full
backend suite: 820 passed, 14 skipped; frontend builds clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
135 lines
5.3 KiB
Python
135 lines
5.3 KiB
Python
"""
|
|
Build Packs API — zero-Dockerfile detection + Dockerfile/compose generation.
|
|
|
|
Mounted at /api/v1/buildpacks (registered in app/__init__.py).
|
|
|
|
Two endpoints:
|
|
* POST /detect — clone a repo to a throwaway workspace, run BuildpackService
|
|
.detect(), return the plan + a generated Dockerfile preview.
|
|
Results are cached by (repo_url, commit) so repeated detects
|
|
on an unchanged repo are cheap. Accepts an already-cloned
|
|
``path`` to skip cloning (used by tests / advanced callers).
|
|
* POST /generate — pure: given a plan dict, return Dockerfile + compose.
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
|
|
from flask import Blueprint, request, jsonify
|
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
|
|
|
from app.services.buildpack_service import BuildpackService
|
|
from app.services.git_service import GitService
|
|
from app.services.source_connection_service import SourceConnectionService
|
|
|
|
buildpacks_bp = Blueprint('buildpacks', __name__)
|
|
|
|
|
|
def _build_response(plan: dict, app_name: str = 'app') -> dict:
|
|
"""Attach generated artifacts to a plan for the client."""
|
|
return {
|
|
'plan': plan,
|
|
'dockerfile': BuildpackService.generate_dockerfile(plan),
|
|
'compose': BuildpackService.generate_compose(plan, app_name),
|
|
}
|
|
|
|
|
|
@buildpacks_bp.route('/detect', methods=['POST'])
|
|
@jwt_required()
|
|
def detect():
|
|
"""Detect the build pack for a repository.
|
|
|
|
Body: {repo_url?, branch?, source_connection_id?, repository_full_name?, path?}
|
|
|
|
If ``path`` is supplied it is used directly (no clone). Otherwise the repo is
|
|
cloned shallowly to a temp workspace, inspected, and the workspace removed.
|
|
"""
|
|
data = request.get_json() or {}
|
|
current_user_id = get_jwt_identity()
|
|
|
|
explicit_path = (data.get('path') or '').strip() or None
|
|
repo_url = (data.get('repo_url') or '').strip()
|
|
branch = (data.get('branch') or '').strip() or None
|
|
source_connection_id = data.get('source_connection_id')
|
|
repository_full_name = (data.get('repository_full_name') or '').strip()
|
|
app_name = (data.get('name') or 'app').strip() or 'app'
|
|
|
|
# ---- Path supplied: detect in place, no clone. --------------------------
|
|
if explicit_path:
|
|
if not os.path.isdir(explicit_path):
|
|
return jsonify({'error': f'Path does not exist: {explicit_path}'}), 400
|
|
plan = BuildpackService.detect(explicit_path)
|
|
return jsonify(_build_response(plan, app_name)), 200
|
|
|
|
# ---- Resolve the clone URL (optionally via a stored source connection). --
|
|
clone_url = repo_url
|
|
display_url = repo_url
|
|
if source_connection_id:
|
|
try:
|
|
source_repo = SourceConnectionService.get_authenticated_clone_url(
|
|
user_id=current_user_id,
|
|
connection_id=int(source_connection_id),
|
|
full_name=repository_full_name,
|
|
)
|
|
clone_url = source_repo['clone_url']
|
|
display_url = source_repo['public_url']
|
|
except (TypeError, ValueError) as exc:
|
|
return jsonify({'error': str(exc)}), 400
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
return jsonify({'error': f'Failed to resolve source connection: {exc}'}), 400
|
|
|
|
if not clone_url:
|
|
return jsonify({'error': 'repo_url or path is required'}), 400
|
|
|
|
# ---- Cache hit on (display_url, branch)? --------------------------------
|
|
cache_key_url = display_url or clone_url
|
|
cached = BuildpackService.get_cached_plan(cache_key_url, branch)
|
|
if cached:
|
|
return jsonify({**_build_response(cached, app_name), 'cached': True}), 200
|
|
|
|
# ---- Clone shallowly to a throwaway workspace, detect, clean up. --------
|
|
workdir = tempfile.mkdtemp(prefix='serverkit-buildpack-')
|
|
clone_target = os.path.join(workdir, 'repo')
|
|
try:
|
|
clone_result = GitService.clone_repository(clone_target, clone_url, branch)
|
|
if not clone_result.get('success'):
|
|
err = clone_result.get('error') or 'Failed to clone repository'
|
|
# Never leak the authenticated clone URL (it may embed a token).
|
|
if clone_url != display_url and display_url:
|
|
err = err.replace(clone_url, display_url)
|
|
return jsonify({'error': err}), 400
|
|
|
|
commit = None
|
|
info = GitService.get_commit_info(clone_target)
|
|
if info:
|
|
commit = info.get('hash') or info.get('commit')
|
|
|
|
plan = BuildpackService.detect(clone_target)
|
|
BuildpackService.cache_plan(cache_key_url, plan, commit or branch)
|
|
return jsonify(_build_response(plan, app_name)), 200
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
return jsonify({'error': str(exc)}), 400
|
|
finally:
|
|
shutil.rmtree(workdir, ignore_errors=True)
|
|
|
|
|
|
@buildpacks_bp.route('/generate', methods=['POST'])
|
|
@jwt_required()
|
|
def generate():
|
|
"""Generate a Dockerfile + compose from a plan (pure, no clone).
|
|
|
|
Body: {plan, overrides?, name?}
|
|
"""
|
|
data = request.get_json() or {}
|
|
plan = data.get('plan')
|
|
if not isinstance(plan, dict):
|
|
return jsonify({'error': 'plan (object) is required'}), 400
|
|
|
|
overrides = data.get('overrides')
|
|
if overrides:
|
|
plan = BuildpackService.apply_overrides(plan, overrides)
|
|
|
|
app_name = (data.get('name') or 'app').strip() or 'app'
|
|
return jsonify(_build_response(plan, app_name)), 200
|