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:
Mondo Diaz
2026-01-27 15:29:51 +00:00
parent 6c8b922818
commit ba7cd96107
24 changed files with 4894 additions and 29 deletions

View File

@@ -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,
}
)