Add package dependencies and project settings page
Package Dependencies: - Add artifact dependency management system - Add dependency API endpoints (get, resolve, reverse) - Add ensure file parsing for declaring dependencies - Add circular dependency and conflict detection - Add frontend dependency visualization with graph modal - Add migration for artifact_dependencies table Project Settings Page (#65): - Add dedicated settings page for project admins - General settings section (description, visibility) - Access management section (moved from project page) - Danger zone with inline delete confirmation - Add Settings button to project page header
This commit is contained in:
@@ -14,7 +14,7 @@ from fastapi import (
|
||||
Cookie,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import StreamingResponse, RedirectResponse
|
||||
from fastapi.responses import StreamingResponse, RedirectResponse, PlainTextResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_, and_, func, text
|
||||
from typing import List, Optional, Literal
|
||||
@@ -47,6 +47,7 @@ from .models import (
|
||||
User,
|
||||
AccessPermission,
|
||||
PackageVersion,
|
||||
ArtifactDependency,
|
||||
)
|
||||
from .schemas import (
|
||||
ProjectCreate,
|
||||
@@ -120,8 +121,28 @@ from .schemas import (
|
||||
OIDCLoginResponse,
|
||||
PackageVersionResponse,
|
||||
PackageVersionDetailResponse,
|
||||
ArtifactDependenciesResponse,
|
||||
DependencyResponse,
|
||||
ReverseDependenciesResponse,
|
||||
DependencyResolutionResponse,
|
||||
CircularDependencyError as CircularDependencyErrorSchema,
|
||||
DependencyConflictError as DependencyConflictErrorSchema,
|
||||
)
|
||||
from .metadata import extract_metadata
|
||||
from .dependencies import (
|
||||
parse_ensure_file,
|
||||
validate_dependencies,
|
||||
store_dependencies,
|
||||
get_artifact_dependencies,
|
||||
get_reverse_dependencies,
|
||||
check_circular_dependencies,
|
||||
resolve_dependencies,
|
||||
InvalidEnsureFileError,
|
||||
CircularDependencyError,
|
||||
DependencyConflictError,
|
||||
DependencyNotFoundError,
|
||||
DependencyDepthExceededError,
|
||||
)
|
||||
from .config import get_settings
|
||||
from .checksum import (
|
||||
ChecksumMismatchError,
|
||||
@@ -144,6 +165,18 @@ def sanitize_filename(filename: str) -> str:
|
||||
return re.sub(r'[\r\n"]', "", filename)
|
||||
|
||||
|
||||
def read_ensure_file(ensure_file: UploadFile) -> bytes:
|
||||
"""Read the content of an ensure file upload.
|
||||
|
||||
Args:
|
||||
ensure_file: The uploaded ensure file
|
||||
|
||||
Returns:
|
||||
Raw bytes content of the file
|
||||
"""
|
||||
return ensure_file.file.read()
|
||||
|
||||
|
||||
def build_content_disposition(filename: str) -> str:
|
||||
"""Build a Content-Disposition header value with proper encoding.
|
||||
|
||||
@@ -2272,6 +2305,7 @@ def upload_artifact(
|
||||
package_name: str,
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
ensure: Optional[UploadFile] = File(None, description="Optional orchard.ensure file with dependencies"),
|
||||
tag: Optional[str] = Form(None),
|
||||
version: Optional[str] = Form(None),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -2303,6 +2337,26 @@ def upload_artifact(
|
||||
- `throughput_mbps`: Upload throughput in MB/s
|
||||
- `deduplicated`: True if content already existed
|
||||
|
||||
**Dependencies (orchard.ensure file):**
|
||||
Optionally include an `ensure` file to declare dependencies for this artifact.
|
||||
The file must be valid YAML with the following format:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
- project: some-project
|
||||
package: some-lib
|
||||
version: "1.2.3" # Exact version (mutually exclusive with tag)
|
||||
|
||||
- project: another-project
|
||||
package: another-lib
|
||||
tag: stable # Tag reference (mutually exclusive with version)
|
||||
```
|
||||
|
||||
**Dependency validation:**
|
||||
- Each dependency must specify either `version` or `tag`, not both
|
||||
- Referenced projects must exist (packages are not validated at upload time)
|
||||
- Circular dependencies are rejected at upload time
|
||||
|
||||
**Example (curl):**
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/v1/project/myproject/mypackage/upload" \\
|
||||
@@ -2311,6 +2365,15 @@ def upload_artifact(
|
||||
-F "tag=v1.0.0"
|
||||
```
|
||||
|
||||
**Example with dependencies (curl):**
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/v1/project/myproject/mypackage/upload" \\
|
||||
-H "Authorization: Bearer <api-key>" \\
|
||||
-F "file=@myfile.tar.gz" \\
|
||||
-F "ensure=@orchard.ensure" \\
|
||||
-F "tag=v1.0.0"
|
||||
```
|
||||
|
||||
**Example (Python requests):**
|
||||
```python
|
||||
import requests
|
||||
@@ -2611,6 +2674,45 @@ def upload_artifact(
|
||||
f"ref_count={artifact.ref_count}, saved_bytes={saved_bytes}"
|
||||
)
|
||||
|
||||
# Process ensure file if provided
|
||||
dependencies_stored = []
|
||||
if ensure:
|
||||
try:
|
||||
ensure_content = read_ensure_file(ensure)
|
||||
parsed_ensure = parse_ensure_file(ensure_content)
|
||||
|
||||
if parsed_ensure.dependencies:
|
||||
# Validate dependencies (projects must exist)
|
||||
validation_errors = validate_dependencies(db, parsed_ensure.dependencies)
|
||||
if validation_errors:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid ensure file: {'; '.join(validation_errors)}"
|
||||
)
|
||||
|
||||
# Check for circular dependencies
|
||||
cycle = check_circular_dependencies(
|
||||
db, storage_result.sha256, parsed_ensure.dependencies,
|
||||
project_name=project_name, package_name=package_name
|
||||
)
|
||||
if cycle:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Circular dependency detected: {' -> '.join(cycle)}"
|
||||
)
|
||||
|
||||
# Store dependencies
|
||||
dependencies_stored = store_dependencies(
|
||||
db, storage_result.sha256, parsed_ensure.dependencies
|
||||
)
|
||||
logger.info(
|
||||
f"Stored {len(dependencies_stored)} dependencies for artifact "
|
||||
f"{storage_result.sha256[:12]}..."
|
||||
)
|
||||
|
||||
except InvalidEnsureFileError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid ensure file: {e}")
|
||||
|
||||
# Audit log
|
||||
_log_audit(
|
||||
db,
|
||||
@@ -6508,3 +6610,324 @@ def factory_reset(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Factory reset failed: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dependency Management Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/artifact/{artifact_id}/dependencies",
|
||||
response_model=ArtifactDependenciesResponse,
|
||||
tags=["dependencies"],
|
||||
)
|
||||
def get_artifact_dependencies_endpoint(
|
||||
artifact_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""
|
||||
Get all dependencies for an artifact by its ID.
|
||||
|
||||
Returns the list of packages this artifact depends on.
|
||||
"""
|
||||
# Verify artifact exists
|
||||
artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
|
||||
if not artifact:
|
||||
raise HTTPException(status_code=404, detail="Artifact not found")
|
||||
|
||||
deps = get_artifact_dependencies(db, artifact_id)
|
||||
|
||||
return ArtifactDependenciesResponse(
|
||||
artifact_id=artifact_id,
|
||||
dependencies=deps,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/project/{project_name}/{package_name}/+/{ref}/dependencies",
|
||||
response_model=ArtifactDependenciesResponse,
|
||||
tags=["dependencies"],
|
||||
)
|
||||
def get_dependencies_by_ref(
|
||||
project_name: str,
|
||||
package_name: str,
|
||||
ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""
|
||||
Get dependencies for an artifact by project/package/ref.
|
||||
|
||||
The ref can be a tag name or version.
|
||||
"""
|
||||
# Check project access (handles private project authorization)
|
||||
project = check_project_access(db, project_name, current_user, "read")
|
||||
|
||||
package = db.query(Package).filter(
|
||||
Package.project_id == project.id,
|
||||
Package.name == package_name,
|
||||
).first()
|
||||
if not package:
|
||||
raise HTTPException(status_code=404, detail="Package not found")
|
||||
|
||||
# Try to resolve ref to an artifact
|
||||
artifact_id = None
|
||||
|
||||
# Try as tag first
|
||||
tag = db.query(Tag).filter(
|
||||
Tag.package_id == package.id,
|
||||
Tag.name == ref,
|
||||
).first()
|
||||
if tag:
|
||||
artifact_id = tag.artifact_id
|
||||
|
||||
# Try as version if not found as tag
|
||||
if not artifact_id:
|
||||
version_record = db.query(PackageVersion).filter(
|
||||
PackageVersion.package_id == package.id,
|
||||
PackageVersion.version == ref,
|
||||
).first()
|
||||
if version_record:
|
||||
artifact_id = version_record.artifact_id
|
||||
|
||||
# Try as artifact ID prefix
|
||||
if not artifact_id and len(ref) >= 8:
|
||||
artifact = db.query(Artifact).filter(
|
||||
Artifact.id.like(f"{ref}%")
|
||||
).first()
|
||||
if artifact:
|
||||
artifact_id = artifact.id
|
||||
|
||||
if not artifact_id:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Reference '{ref}' not found in {project_name}/{package_name}"
|
||||
)
|
||||
|
||||
deps = get_artifact_dependencies(db, artifact_id)
|
||||
|
||||
return ArtifactDependenciesResponse(
|
||||
artifact_id=artifact_id,
|
||||
dependencies=deps,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/project/{project_name}/{package_name}/+/{ref}/ensure",
|
||||
response_class=PlainTextResponse,
|
||||
tags=["dependencies"],
|
||||
)
|
||||
def get_ensure_file(
|
||||
project_name: str,
|
||||
package_name: str,
|
||||
ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""
|
||||
Get the orchard.ensure file content for an artifact.
|
||||
|
||||
Returns the dependencies in YAML format that can be used as an ensure file.
|
||||
"""
|
||||
# Check project access
|
||||
project = check_project_access(db, project_name, current_user, "read")
|
||||
|
||||
package = db.query(Package).filter(
|
||||
Package.project_id == project.id,
|
||||
Package.name == package_name,
|
||||
).first()
|
||||
if not package:
|
||||
raise HTTPException(status_code=404, detail="Package not found")
|
||||
|
||||
# Resolve ref to artifact
|
||||
artifact_id = None
|
||||
|
||||
# Try as tag first
|
||||
tag = db.query(Tag).filter(
|
||||
Tag.package_id == package.id,
|
||||
Tag.name == ref,
|
||||
).first()
|
||||
if tag:
|
||||
artifact_id = tag.artifact_id
|
||||
|
||||
# Try as version
|
||||
if not artifact_id:
|
||||
version = db.query(PackageVersion).filter(
|
||||
PackageVersion.package_id == package.id,
|
||||
PackageVersion.version == ref,
|
||||
).first()
|
||||
if version:
|
||||
artifact_id = version.artifact_id
|
||||
|
||||
# Try as artifact ID prefix
|
||||
if not artifact_id and len(ref) >= 8:
|
||||
artifact = db.query(Artifact).filter(
|
||||
Artifact.id.like(f"{ref}%")
|
||||
).first()
|
||||
if artifact:
|
||||
artifact_id = artifact.id
|
||||
|
||||
if not artifact_id:
|
||||
raise HTTPException(status_code=404, detail="Artifact not found")
|
||||
|
||||
# Get artifact details
|
||||
artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
|
||||
|
||||
# Get version info if available
|
||||
version_record = db.query(PackageVersion).filter(
|
||||
PackageVersion.package_id == package.id,
|
||||
PackageVersion.artifact_id == artifact_id,
|
||||
).first()
|
||||
version_str = version_record.version if version_record else None
|
||||
|
||||
# Get dependencies
|
||||
deps = get_artifact_dependencies(db, artifact_id)
|
||||
|
||||
# Build YAML content with full format
|
||||
lines = []
|
||||
|
||||
# Header comment
|
||||
lines.append(f"# orchard.ensure - Generated from {project_name}/{package_name}@{ref}")
|
||||
lines.append(f"# Artifact: {artifact_id}")
|
||||
if version_str:
|
||||
lines.append(f"# Version: {version_str}")
|
||||
lines.append(f"# Generated: {datetime.now(timezone.utc).isoformat()}")
|
||||
lines.append("")
|
||||
|
||||
# Top-level project
|
||||
lines.append(f"project: {project_name}")
|
||||
lines.append("")
|
||||
|
||||
# Projects section
|
||||
lines.append("projects:")
|
||||
lines.append(f" - name: {project_name}")
|
||||
|
||||
if deps:
|
||||
lines.append(" dependencies:")
|
||||
for dep in deps:
|
||||
# Determine if cross-project dependency
|
||||
is_cross_project = dep.project != project_name
|
||||
|
||||
lines.append(f" - package: {dep.package}")
|
||||
if is_cross_project:
|
||||
lines.append(f" project: {dep.project} # Cross-project dependency")
|
||||
if dep.version:
|
||||
lines.append(f" version: \"{dep.version}\"")
|
||||
elif dep.tag:
|
||||
lines.append(f" tag: {dep.tag}")
|
||||
# Suggest a path based on package name
|
||||
lines.append(f" path: {dep.package}/")
|
||||
else:
|
||||
lines.append(" dependencies: []")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return PlainTextResponse(
|
||||
"\n".join(lines),
|
||||
media_type="text/yaml",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/project/{project_name}/{package_name}/reverse-dependencies",
|
||||
response_model=ReverseDependenciesResponse,
|
||||
tags=["dependencies"],
|
||||
)
|
||||
def get_package_reverse_dependencies(
|
||||
project_name: str,
|
||||
package_name: str,
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""
|
||||
Get packages that depend on this package (reverse dependencies).
|
||||
|
||||
Returns a paginated list of artifacts that declare a dependency on this package.
|
||||
"""
|
||||
# Check project access (handles private project authorization)
|
||||
project = check_project_access(db, project_name, current_user, "read")
|
||||
|
||||
package = db.query(Package).filter(
|
||||
Package.project_id == project.id,
|
||||
Package.name == package_name,
|
||||
).first()
|
||||
if not package:
|
||||
raise HTTPException(status_code=404, detail="Package not found")
|
||||
|
||||
return get_reverse_dependencies(db, project_name, package_name, page, limit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/project/{project_name}/{package_name}/+/{ref}/resolve",
|
||||
response_model=DependencyResolutionResponse,
|
||||
tags=["dependencies"],
|
||||
)
|
||||
def resolve_artifact_dependencies(
|
||||
project_name: str,
|
||||
package_name: str,
|
||||
ref: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""
|
||||
Resolve all dependencies for an artifact recursively.
|
||||
|
||||
Returns a flat list of all artifacts needed, in topological order
|
||||
(dependencies before dependents). Includes download URLs for each artifact.
|
||||
|
||||
**Error Responses:**
|
||||
- 404: Artifact or dependency not found
|
||||
- 409: Circular dependency or version conflict detected
|
||||
"""
|
||||
# Check project access (handles private project authorization)
|
||||
check_project_access(db, project_name, current_user, "read")
|
||||
|
||||
# Build base URL for download links
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
|
||||
try:
|
||||
return resolve_dependencies(db, project_name, package_name, ref, base_url)
|
||||
except DependencyNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Dependency not found: {e.project}/{e.package}@{e.constraint}"
|
||||
)
|
||||
except CircularDependencyError as e:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"error": "circular_dependency",
|
||||
"message": str(e),
|
||||
"cycle": e.cycle,
|
||||
}
|
||||
)
|
||||
except DependencyConflictError as e:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"error": "dependency_conflict",
|
||||
"message": str(e),
|
||||
"conflicts": [
|
||||
{
|
||||
"project": c.project,
|
||||
"package": c.package,
|
||||
"requirements": c.requirements,
|
||||
}
|
||||
for c in e.conflicts
|
||||
],
|
||||
}
|
||||
)
|
||||
except DependencyDepthExceededError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "dependency_depth_exceeded",
|
||||
"message": str(e),
|
||||
"max_depth": e.max_depth,
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user