Merge branch 'feature/separate-version-tag' into 'main'
Add separate version tracking for artifacts Closes orchard-python-poetry#56 See merge request esv/bsf/bsf-integration/orchard/orchard-mvp!33
This commit is contained in:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
|
- Added `package_versions` table for immutable version tracking separate from mutable tags (#56)
|
||||||
|
- Versions are set at upload time via explicit `version` parameter or auto-detected from filename/metadata
|
||||||
|
- Version detection priority: explicit parameter > package metadata > filename pattern
|
||||||
|
- Versions are immutable once created (unlike tags which can be moved)
|
||||||
|
- Added version API endpoints (#56):
|
||||||
|
- `GET /api/v1/project/{project}/{package}/versions` - List all versions for a package
|
||||||
|
- `GET /api/v1/project/{project}/{package}/versions/{version}` - Get specific version details
|
||||||
|
- `DELETE /api/v1/project/{project}/{package}/versions/{version}` - Delete a version (admin only)
|
||||||
|
- Added version support to upload endpoint via `version` form parameter (#56)
|
||||||
|
- Added `version:X.Y.Z` prefix for explicit version resolution in download refs (#56)
|
||||||
|
- Added version field to tag responses (shows which version the artifact has, if any) (#56)
|
||||||
|
- Added migration `007_package_versions.sql` with ref_count triggers and data migration from semver tags (#56)
|
||||||
- Added production deployment job triggered by semantic version tags (v1.0.0) with manual approval gate (#63)
|
- Added production deployment job triggered by semantic version tags (v1.0.0) with manual approval gate (#63)
|
||||||
- Added production Helm values file with persistence enabled (20Gi PostgreSQL, 100Gi MinIO) (#63)
|
- Added production Helm values file with persistence enabled (20Gi PostgreSQL, 100Gi MinIO) (#63)
|
||||||
- Added integration tests for production deployment (#63)
|
- Added integration tests for production deployment (#63)
|
||||||
@@ -19,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Added internal proxy configuration for npm, pip, helm, and apt (#51)
|
- Added internal proxy configuration for npm, pip, helm, and apt (#51)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- Updated download ref resolution to check versions before tags (version → tag → artifact ID) (#56)
|
||||||
- Deploy jobs now require all security scans to pass before deployment (added test_image, app_deps_scan, cve_scan, cve_sbom_analysis, app_sbom_analysis to dependencies) (#63)
|
- Deploy jobs now require all security scans to pass before deployment (added test_image, app_deps_scan, cve_scan, cve_sbom_analysis, app_sbom_analysis to dependencies) (#63)
|
||||||
- Increased deploy job timeout from 5m to 10m (#63)
|
- Increased deploy job timeout from 5m to 10m (#63)
|
||||||
- Added `--atomic` flag to Helm deployments for automatic rollback on failure
|
- Added `--atomic` flag to Helm deployments for automatic rollback on failure
|
||||||
|
|||||||
60
README.md
60
README.md
@@ -22,6 +22,7 @@ Orchard is a centralized binary artifact storage system that provides content-ad
|
|||||||
- **Package** - Named collection within a project
|
- **Package** - Named collection within a project
|
||||||
- **Artifact** - Specific content instance identified by SHA256
|
- **Artifact** - Specific content instance identified by SHA256
|
||||||
- **Tags** - Alias system for referencing artifacts by human-readable names (e.g., `v1.0.0`, `latest`, `stable`)
|
- **Tags** - Alias system for referencing artifacts by human-readable names (e.g., `v1.0.0`, `latest`, `stable`)
|
||||||
|
- **Versions** - Immutable version records set at upload time (explicit or auto-detected from filename/metadata), separate from mutable tags
|
||||||
- **Package Formats & Platforms** - Packages can be tagged with format (npm, pypi, docker, deb, rpm, etc.) and platform (linux, darwin, windows, etc.)
|
- **Package Formats & Platforms** - Packages can be tagged with format (npm, pypi, docker, deb, rpm, etc.) and platform (linux, darwin, windows, etc.)
|
||||||
- **Rich Package Metadata** - Package listings include aggregated stats (tag count, artifact count, total size, latest tag)
|
- **Rich Package Metadata** - Package listings include aggregated stats (tag count, artifact count, total size, latest tag)
|
||||||
- **S3-Compatible Backend** - Uses MinIO (or any S3-compatible storage) for artifact storage
|
- **S3-Compatible Backend** - Uses MinIO (or any S3-compatible storage) for artifact storage
|
||||||
@@ -73,6 +74,9 @@ Orchard is a centralized binary artifact storage system that provides content-ad
|
|||||||
| `POST` | `/api/v1/project/:project/:package/tags` | Create a tag |
|
| `POST` | `/api/v1/project/:project/:package/tags` | Create a tag |
|
||||||
| `GET` | `/api/v1/project/:project/:package/tags/:tag_name` | Get single tag with artifact metadata |
|
| `GET` | `/api/v1/project/:project/:package/tags/:tag_name` | Get single tag with artifact metadata |
|
||||||
| `GET` | `/api/v1/project/:project/:package/tags/:tag_name/history` | Get tag change history |
|
| `GET` | `/api/v1/project/:project/:package/tags/:tag_name/history` | Get tag change history |
|
||||||
|
| `GET` | `/api/v1/project/:project/:package/versions` | List all versions for a package |
|
||||||
|
| `GET` | `/api/v1/project/:project/:package/versions/:version` | Get specific version details |
|
||||||
|
| `DELETE` | `/api/v1/project/:project/:package/versions/:version` | Delete a version (admin only) |
|
||||||
| `GET` | `/api/v1/project/:project/:package/artifacts` | List artifacts in package (with filtering) |
|
| `GET` | `/api/v1/project/:project/:package/artifacts` | List artifacts in package (with filtering) |
|
||||||
| `GET` | `/api/v1/project/:project/:package/consumers` | List consumers of a package |
|
| `GET` | `/api/v1/project/:project/:package/consumers` | List consumers of a package |
|
||||||
| `GET` | `/api/v1/artifact/:id` | Get artifact metadata with referencing tags |
|
| `GET` | `/api/v1/artifact/:id` | Get artifact metadata with referencing tags |
|
||||||
@@ -93,12 +97,14 @@ For large files, use the resumable upload API:
|
|||||||
|
|
||||||
When downloading artifacts, the `:ref` parameter supports multiple formats:
|
When downloading artifacts, the `:ref` parameter supports multiple formats:
|
||||||
|
|
||||||
- `latest` - Tag name directly
|
- `latest` - Implicit lookup (checks version first, then tag, then artifact ID)
|
||||||
- `v1.0.0` - Version tag
|
- `v1.0.0` - Implicit lookup (version takes precedence over tag with same name)
|
||||||
|
- `version:1.0.0` - Explicit version reference
|
||||||
- `tag:stable` - Explicit tag reference
|
- `tag:stable` - Explicit tag reference
|
||||||
- `version:2024.1` - Version reference
|
|
||||||
- `artifact:a3f5d8e12b4c6789...` - Direct SHA256 hash reference
|
- `artifact:a3f5d8e12b4c6789...` - Direct SHA256 hash reference
|
||||||
|
|
||||||
|
**Resolution order for implicit refs:** version → tag → artifact ID
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -230,9 +236,16 @@ curl "http://localhost:8080/api/v1/project/my-project/packages/releases?include_
|
|||||||
### Upload an Artifact
|
### Upload an Artifact
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Upload with tag only (version auto-detected from filename)
|
||||||
curl -X POST http://localhost:8080/api/v1/project/my-project/releases/upload \
|
curl -X POST http://localhost:8080/api/v1/project/my-project/releases/upload \
|
||||||
-F "file=@./build/app-v1.0.0.tar.gz" \
|
-F "file=@./build/app-v1.0.0.tar.gz" \
|
||||||
-F "tag=v1.0.0"
|
-F "tag=latest"
|
||||||
|
|
||||||
|
# Upload with explicit version and tag
|
||||||
|
curl -X POST http://localhost:8080/api/v1/project/my-project/releases/upload \
|
||||||
|
-F "file=@./build/app-v1.0.0.tar.gz" \
|
||||||
|
-F "tag=latest" \
|
||||||
|
-F "version=1.0.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
Response:
|
Response:
|
||||||
@@ -242,7 +255,9 @@ Response:
|
|||||||
"size": 1048576,
|
"size": 1048576,
|
||||||
"project": "my-project",
|
"project": "my-project",
|
||||||
"package": "releases",
|
"package": "releases",
|
||||||
"tag": "v1.0.0",
|
"tag": "latest",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"version_source": "explicit",
|
||||||
"format_metadata": {
|
"format_metadata": {
|
||||||
"format": "tarball",
|
"format": "tarball",
|
||||||
"package_name": "app",
|
"package_name": "app",
|
||||||
@@ -400,6 +415,38 @@ curl http://localhost:8080/api/v1/project/my-project/releases/tags/latest/histor
|
|||||||
|
|
||||||
Returns list of artifact changes for the tag (most recent first).
|
Returns list of artifact changes for the tag (most recent first).
|
||||||
|
|
||||||
|
### List Versions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic listing
|
||||||
|
curl http://localhost:8080/api/v1/project/my-project/releases/versions
|
||||||
|
|
||||||
|
# With pagination and sorting
|
||||||
|
curl "http://localhost:8080/api/v1/project/my-project/releases/versions?sort=version&order=desc"
|
||||||
|
```
|
||||||
|
|
||||||
|
Response includes tags pointing to each version's artifact:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"package_id": "uuid",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"version_source": "explicit",
|
||||||
|
"artifact_id": "a3f5d8e...",
|
||||||
|
"size": 1048576,
|
||||||
|
"content_type": "application/gzip",
|
||||||
|
"original_name": "app-v1.0.0.tar.gz",
|
||||||
|
"created_at": "2025-01-01T00:00:00Z",
|
||||||
|
"created_by": "user",
|
||||||
|
"tags": ["latest", "stable"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": {"page": 1, "limit": 20, "total": 1, "total_pages": 1}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### List Artifacts in Package
|
### List Artifacts in Package
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -614,7 +661,8 @@ See `helm/orchard/values.yaml` for all configuration options.
|
|||||||
- **projects** - Top-level organizational containers
|
- **projects** - Top-level organizational containers
|
||||||
- **packages** - Collections within projects
|
- **packages** - Collections within projects
|
||||||
- **artifacts** - Content-addressable artifacts (SHA256)
|
- **artifacts** - Content-addressable artifacts (SHA256)
|
||||||
- **tags** - Aliases pointing to artifacts
|
- **tags** - Mutable aliases pointing to artifacts
|
||||||
|
- **package_versions** - Immutable version records (set at upload time)
|
||||||
- **tag_history** - Audit trail for tag changes
|
- **tag_history** - Audit trail for tag changes
|
||||||
- **uploads** - Upload event records
|
- **uploads** - Upload event records
|
||||||
- **consumers** - Dependency tracking
|
- **consumers** - Dependency tracking
|
||||||
|
|||||||
@@ -151,6 +151,84 @@ def _run_migrations():
|
|||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
""",
|
""",
|
||||||
|
# Add package_versions indexes and triggers (007_package_versions.sql)
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Create indexes for package_versions if table exists
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'package_versions') THEN
|
||||||
|
-- Indexes for common queries
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_package_versions_package_id') THEN
|
||||||
|
CREATE INDEX idx_package_versions_package_id ON package_versions(package_id);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_package_versions_artifact_id') THEN
|
||||||
|
CREATE INDEX idx_package_versions_artifact_id ON package_versions(artifact_id);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_package_versions_package_version') THEN
|
||||||
|
CREATE INDEX idx_package_versions_package_version ON package_versions(package_id, version);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
""",
|
||||||
|
# Create ref_count trigger functions for package_versions
|
||||||
|
"""
|
||||||
|
CREATE OR REPLACE FUNCTION increment_version_ref_count()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE artifacts SET ref_count = ref_count + 1 WHERE id = NEW.artifact_id;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE OR REPLACE FUNCTION decrement_version_ref_count()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE artifacts SET ref_count = ref_count - 1 WHERE id = OLD.artifact_id;
|
||||||
|
RETURN OLD;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
""",
|
||||||
|
# Create triggers for package_versions ref_count
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'package_versions') THEN
|
||||||
|
-- Drop and recreate triggers to ensure they're current
|
||||||
|
DROP TRIGGER IF EXISTS package_versions_ref_count_insert ON package_versions;
|
||||||
|
CREATE TRIGGER package_versions_ref_count_insert
|
||||||
|
AFTER INSERT ON package_versions
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION increment_version_ref_count();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS package_versions_ref_count_delete ON package_versions;
|
||||||
|
CREATE TRIGGER package_versions_ref_count_delete
|
||||||
|
AFTER DELETE ON package_versions
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION decrement_version_ref_count();
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
""",
|
||||||
|
# Migrate existing semver tags to package_versions
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'package_versions') THEN
|
||||||
|
-- Migrate tags that look like versions (v1.0.0, 1.2.3, 2.0.0-beta, etc.)
|
||||||
|
INSERT INTO package_versions (package_id, artifact_id, version, version_source, created_by, created_at)
|
||||||
|
SELECT
|
||||||
|
t.package_id,
|
||||||
|
t.artifact_id,
|
||||||
|
CASE WHEN t.name LIKE 'v%' THEN substring(t.name from 2) ELSE t.name END,
|
||||||
|
'migrated_from_tag',
|
||||||
|
t.created_by,
|
||||||
|
t.created_at
|
||||||
|
FROM tags t
|
||||||
|
WHERE t.name ~ '^v?[0-9]+\.[0-9]+(\.[0-9]+)?([-.][a-zA-Z0-9]+)?$'
|
||||||
|
ON CONFLICT (package_id, version) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
|
|||||||
@@ -245,9 +245,10 @@ def extract_tarball_metadata(file: BinaryIO, filename: str) -> Dict[str, Any]:
|
|||||||
break
|
break
|
||||||
|
|
||||||
# Try to split name and version
|
# Try to split name and version
|
||||||
|
# Handle optional 'v' prefix on version (e.g., package-v1.0.0)
|
||||||
patterns = [
|
patterns = [
|
||||||
r"^(.+)-(\d+\.\d+(?:\.\d+)?(?:[-._]\w+)?)$", # name-version
|
r"^(.+)-v?(\d+\.\d+(?:\.\d+)?(?:[-_]\w+)?)$", # name-version or name-vversion
|
||||||
r"^(.+)_(\d+\.\d+(?:\.\d+)?(?:[-._]\w+)?)$", # name_version
|
r"^(.+)_v?(\d+\.\d+(?:\.\d+)?(?:[-_]\w+)?)$", # name_version or name_vversion
|
||||||
]
|
]
|
||||||
|
|
||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ class Package(Base):
|
|||||||
consumers = relationship(
|
consumers = relationship(
|
||||||
"Consumer", back_populates="package", cascade="all, delete-orphan"
|
"Consumer", back_populates="package", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
versions = relationship(
|
||||||
|
"PackageVersion", back_populates="package", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("idx_packages_project_id", "project_id"),
|
Index("idx_packages_project_id", "project_id"),
|
||||||
@@ -113,6 +116,7 @@ class Artifact(Base):
|
|||||||
|
|
||||||
tags = relationship("Tag", back_populates="artifact")
|
tags = relationship("Tag", back_populates="artifact")
|
||||||
uploads = relationship("Upload", back_populates="artifact")
|
uploads = relationship("Upload", back_populates="artifact")
|
||||||
|
versions = relationship("PackageVersion", back_populates="artifact")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sha256(self) -> str:
|
def sha256(self) -> str:
|
||||||
@@ -197,6 +201,38 @@ class TagHistory(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PackageVersion(Base):
|
||||||
|
"""Immutable version record for a package-artifact relationship.
|
||||||
|
|
||||||
|
Separates versions (immutable, set at upload) from tags (mutable labels).
|
||||||
|
Each artifact in a package can have at most one version.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "package_versions"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
package_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("packages.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
artifact_id = Column(String(64), ForeignKey("artifacts.id"), nullable=False)
|
||||||
|
version = Column(String(255), nullable=False)
|
||||||
|
version_source = Column(String(50)) # 'explicit', 'filename', 'metadata', 'migrated_from_tag'
|
||||||
|
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||||
|
created_by = Column(String(255), nullable=False)
|
||||||
|
|
||||||
|
package = relationship("Package", back_populates="versions")
|
||||||
|
artifact = relationship("Artifact", back_populates="versions")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_package_versions_package_id", "package_id"),
|
||||||
|
Index("idx_package_versions_artifact_id", "artifact_id"),
|
||||||
|
Index("idx_package_versions_package_version", "package_id", "version", unique=True),
|
||||||
|
Index("idx_package_versions_package_artifact", "package_id", "artifact_id", unique=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Upload(Base):
|
class Upload(Base):
|
||||||
__tablename__ = "uploads"
|
__tablename__ = "uploads"
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from fastapi import (
|
|||||||
)
|
)
|
||||||
from fastapi.responses import StreamingResponse, RedirectResponse
|
from fastapi.responses import StreamingResponse, RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import or_, func, text
|
from sqlalchemy import or_, and_, func, text
|
||||||
from typing import List, Optional, Literal
|
from typing import List, Optional, Literal
|
||||||
import math
|
import math
|
||||||
import io
|
import io
|
||||||
@@ -46,6 +46,7 @@ from .models import (
|
|||||||
AuditLog,
|
AuditLog,
|
||||||
User,
|
User,
|
||||||
AccessPermission,
|
AccessPermission,
|
||||||
|
PackageVersion,
|
||||||
)
|
)
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
ProjectCreate,
|
ProjectCreate,
|
||||||
@@ -116,6 +117,8 @@ from .schemas import (
|
|||||||
OIDCConfigUpdate,
|
OIDCConfigUpdate,
|
||||||
OIDCStatusResponse,
|
OIDCStatusResponse,
|
||||||
OIDCLoginResponse,
|
OIDCLoginResponse,
|
||||||
|
PackageVersionResponse,
|
||||||
|
PackageVersionDetailResponse,
|
||||||
)
|
)
|
||||||
from .metadata import extract_metadata
|
from .metadata import extract_metadata
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
@@ -237,6 +240,103 @@ def _decrement_ref_count(db: Session, artifact_id: str) -> int:
|
|||||||
return artifact.ref_count
|
return artifact.ref_count
|
||||||
|
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Regex pattern for detecting version in filename
|
||||||
|
# Matches: name-1.0.0, name_1.0.0, name-v1.0.0, etc.
|
||||||
|
# Supports: X.Y, X.Y.Z, X.Y.Z-alpha, X.Y.Z.beta1, X.Y.Z_rc2
|
||||||
|
VERSION_FILENAME_PATTERN = re.compile(
|
||||||
|
r"[-_]v?(\d+\.\d+(?:\.\d+)?(?:[-_][a-zA-Z0-9]+)?)(?:\.tar|\.zip|\.tgz|\.gz|\.bz2|\.xz|$)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_version(
|
||||||
|
explicit_version: Optional[str],
|
||||||
|
metadata: dict,
|
||||||
|
filename: str,
|
||||||
|
) -> tuple[Optional[str], Optional[str]]:
|
||||||
|
"""
|
||||||
|
Detect version from explicit parameter, metadata, or filename.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. Explicit version parameter (user provided)
|
||||||
|
2. Version from package metadata (deb, rpm, whl, jar)
|
||||||
|
3. Version from filename pattern
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple of (version, source) where source is one of:
|
||||||
|
- 'explicit': User provided the version
|
||||||
|
- 'metadata': Extracted from package metadata
|
||||||
|
- 'filename': Parsed from filename
|
||||||
|
- None: No version could be determined
|
||||||
|
"""
|
||||||
|
# 1. Explicit version takes priority
|
||||||
|
if explicit_version:
|
||||||
|
return explicit_version, "explicit"
|
||||||
|
|
||||||
|
# 2. Try metadata extraction (from deb, rpm, whl, jar, etc.)
|
||||||
|
if metadata.get("version"):
|
||||||
|
return metadata["version"], "metadata"
|
||||||
|
|
||||||
|
# 3. Try filename pattern matching
|
||||||
|
match = VERSION_FILENAME_PATTERN.search(filename)
|
||||||
|
if match:
|
||||||
|
return match.group(1), "filename"
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _create_or_update_version(
|
||||||
|
db: Session,
|
||||||
|
package_id: str,
|
||||||
|
artifact_id: str,
|
||||||
|
version: str,
|
||||||
|
version_source: str,
|
||||||
|
user_id: str,
|
||||||
|
) -> PackageVersion:
|
||||||
|
"""
|
||||||
|
Create a version record for a package-artifact pair.
|
||||||
|
|
||||||
|
Raises HTTPException 409 if version already exists for this package.
|
||||||
|
"""
|
||||||
|
# Check if version already exists
|
||||||
|
existing = (
|
||||||
|
db.query(PackageVersion)
|
||||||
|
.filter(PackageVersion.package_id == package_id, PackageVersion.version == version)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Version {version} already exists in this package",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if artifact already has a version in this package
|
||||||
|
existing_artifact_version = (
|
||||||
|
db.query(PackageVersion)
|
||||||
|
.filter(
|
||||||
|
PackageVersion.package_id == package_id,
|
||||||
|
PackageVersion.artifact_id == artifact_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing_artifact_version:
|
||||||
|
# Artifact already has a version, return it
|
||||||
|
return existing_artifact_version
|
||||||
|
|
||||||
|
# Create new version record
|
||||||
|
pkg_version = PackageVersion(
|
||||||
|
package_id=package_id,
|
||||||
|
artifact_id=artifact_id,
|
||||||
|
version=version,
|
||||||
|
version_source=version_source,
|
||||||
|
created_by=user_id,
|
||||||
|
)
|
||||||
|
db.add(pkg_version)
|
||||||
|
db.flush()
|
||||||
|
return pkg_version
|
||||||
|
|
||||||
|
|
||||||
def _create_or_update_tag(
|
def _create_or_update_tag(
|
||||||
db: Session,
|
db: Session,
|
||||||
package_id: str,
|
package_id: str,
|
||||||
@@ -2147,6 +2247,7 @@ def upload_artifact(
|
|||||||
request: Request,
|
request: Request,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
tag: Optional[str] = Form(None),
|
tag: Optional[str] = Form(None),
|
||||||
|
version: Optional[str] = Form(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
storage: S3Storage = Depends(get_storage),
|
storage: S3Storage = Depends(get_storage),
|
||||||
content_length: Optional[int] = Header(None, alias="Content-Length"),
|
content_length: Optional[int] = Header(None, alias="Content-Length"),
|
||||||
@@ -2214,6 +2315,11 @@ def upload_artifact(
|
|||||||
io.BytesIO(file_content), file.filename, file.content_type
|
io.BytesIO(file_content), file.filename, file.content_type
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Detect version (explicit > metadata > filename)
|
||||||
|
detected_version, version_source = _detect_version(
|
||||||
|
version, file_metadata, file.filename or ""
|
||||||
|
)
|
||||||
|
|
||||||
# Store file (uses multipart for large files) with error handling
|
# Store file (uses multipart for large files) with error handling
|
||||||
try:
|
try:
|
||||||
storage_result = storage.store(file.file, content_length)
|
storage_result = storage.store(file.file, content_length)
|
||||||
@@ -2383,6 +2489,25 @@ def upload_artifact(
|
|||||||
if tag:
|
if tag:
|
||||||
_create_or_update_tag(db, package.id, tag, storage_result.sha256, user_id)
|
_create_or_update_tag(db, package.id, tag, storage_result.sha256, user_id)
|
||||||
|
|
||||||
|
# Create version record if version was detected
|
||||||
|
pkg_version = None
|
||||||
|
if detected_version:
|
||||||
|
try:
|
||||||
|
pkg_version = _create_or_update_version(
|
||||||
|
db, package.id, storage_result.sha256, detected_version, version_source, user_id
|
||||||
|
)
|
||||||
|
except HTTPException as e:
|
||||||
|
# Version conflict (409) - log but don't fail the upload
|
||||||
|
if e.status_code == 409:
|
||||||
|
logger.warning(
|
||||||
|
f"Version {detected_version} already exists for package {package_name}, "
|
||||||
|
f"upload continues without version assignment"
|
||||||
|
)
|
||||||
|
detected_version = None
|
||||||
|
version_source = None
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
# Log deduplication event
|
# Log deduplication event
|
||||||
if deduplicated:
|
if deduplicated:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -2437,6 +2562,8 @@ def upload_artifact(
|
|||||||
project=project_name,
|
project=project_name,
|
||||||
package=package_name,
|
package=package_name,
|
||||||
tag=tag,
|
tag=tag,
|
||||||
|
version=detected_version,
|
||||||
|
version_source=version_source,
|
||||||
checksum_md5=storage_result.md5,
|
checksum_md5=storage_result.md5,
|
||||||
checksum_sha1=storage_result.sha1,
|
checksum_sha1=storage_result.sha1,
|
||||||
s3_etag=storage_result.s3_etag,
|
s3_etag=storage_result.s3_etag,
|
||||||
@@ -2754,15 +2881,30 @@ def _resolve_artifact_ref(
|
|||||||
package: Package,
|
package: Package,
|
||||||
db: Session,
|
db: Session,
|
||||||
) -> Optional[Artifact]:
|
) -> Optional[Artifact]:
|
||||||
"""Resolve a reference (tag name, artifact:hash, tag:name) to an artifact"""
|
"""Resolve a reference (tag name, version, artifact:hash, tag:name, version:X.Y.Z) to an artifact.
|
||||||
|
|
||||||
|
Resolution order for implicit refs (no prefix):
|
||||||
|
1. Version (immutable)
|
||||||
|
2. Tag (mutable)
|
||||||
|
3. Artifact ID (direct hash)
|
||||||
|
"""
|
||||||
artifact = None
|
artifact = None
|
||||||
|
|
||||||
# Check for explicit prefixes
|
# Check for explicit prefixes
|
||||||
if ref.startswith("artifact:"):
|
if ref.startswith("artifact:"):
|
||||||
artifact_id = ref[9:]
|
artifact_id = ref[9:]
|
||||||
artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
|
artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
|
||||||
elif ref.startswith("tag:") or ref.startswith("version:"):
|
elif ref.startswith("version:"):
|
||||||
tag_name = ref.split(":", 1)[1]
|
version_str = ref[8:]
|
||||||
|
pkg_version = (
|
||||||
|
db.query(PackageVersion)
|
||||||
|
.filter(PackageVersion.package_id == package.id, PackageVersion.version == version_str)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if pkg_version:
|
||||||
|
artifact = db.query(Artifact).filter(Artifact.id == pkg_version.artifact_id).first()
|
||||||
|
elif ref.startswith("tag:"):
|
||||||
|
tag_name = ref[4:]
|
||||||
tag = (
|
tag = (
|
||||||
db.query(Tag)
|
db.query(Tag)
|
||||||
.filter(Tag.package_id == package.id, Tag.name == tag_name)
|
.filter(Tag.package_id == package.id, Tag.name == tag_name)
|
||||||
@@ -2771,15 +2913,25 @@ def _resolve_artifact_ref(
|
|||||||
if tag:
|
if tag:
|
||||||
artifact = db.query(Artifact).filter(Artifact.id == tag.artifact_id).first()
|
artifact = db.query(Artifact).filter(Artifact.id == tag.artifact_id).first()
|
||||||
else:
|
else:
|
||||||
# Try as tag name first
|
# Implicit ref: try version first, then tag, then artifact ID
|
||||||
tag = (
|
# Try as version first
|
||||||
db.query(Tag).filter(Tag.package_id == package.id, Tag.name == ref).first()
|
pkg_version = (
|
||||||
|
db.query(PackageVersion)
|
||||||
|
.filter(PackageVersion.package_id == package.id, PackageVersion.version == ref)
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
if tag:
|
if pkg_version:
|
||||||
artifact = db.query(Artifact).filter(Artifact.id == tag.artifact_id).first()
|
artifact = db.query(Artifact).filter(Artifact.id == pkg_version.artifact_id).first()
|
||||||
else:
|
else:
|
||||||
# Try as direct artifact ID
|
# Try as tag name
|
||||||
artifact = db.query(Artifact).filter(Artifact.id == ref).first()
|
tag = (
|
||||||
|
db.query(Tag).filter(Tag.package_id == package.id, Tag.name == ref).first()
|
||||||
|
)
|
||||||
|
if tag:
|
||||||
|
artifact = db.query(Artifact).filter(Artifact.id == tag.artifact_id).first()
|
||||||
|
else:
|
||||||
|
# Try as direct artifact ID
|
||||||
|
artifact = db.query(Artifact).filter(Artifact.id == ref).first()
|
||||||
|
|
||||||
return artifact
|
return artifact
|
||||||
|
|
||||||
@@ -3177,6 +3329,224 @@ def download_artifact_compat(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Version routes
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/project/{project_name}/{package_name}/versions",
|
||||||
|
response_model=PaginatedResponse[PackageVersionResponse],
|
||||||
|
)
|
||||||
|
def list_versions(
|
||||||
|
project_name: str,
|
||||||
|
package_name: str,
|
||||||
|
page: int = Query(default=1, ge=1, description="Page number"),
|
||||||
|
limit: int = Query(default=20, ge=1, le=100, description="Items per page"),
|
||||||
|
search: Optional[str] = Query(default=None, description="Search by version string"),
|
||||||
|
sort: str = Query(default="version", description="Sort field (version, created_at)"),
|
||||||
|
order: str = Query(default="desc", description="Sort order (asc, desc)"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List all versions for a package."""
|
||||||
|
project = db.query(Project).filter(Project.name == project_name).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Validate sort field
|
||||||
|
valid_sort_fields = {"version": PackageVersion.version, "created_at": PackageVersion.created_at}
|
||||||
|
if sort not in valid_sort_fields:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid sort field. Must be one of: {', '.join(valid_sort_fields.keys())}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate order
|
||||||
|
if order not in ("asc", "desc"):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid order. Must be 'asc' or 'desc'")
|
||||||
|
|
||||||
|
# Base query with JOIN to artifact for metadata
|
||||||
|
query = (
|
||||||
|
db.query(PackageVersion, Artifact)
|
||||||
|
.join(Artifact, PackageVersion.artifact_id == Artifact.id)
|
||||||
|
.filter(PackageVersion.package_id == package.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply search filter
|
||||||
|
if search:
|
||||||
|
query = query.filter(PackageVersion.version.ilike(f"%{search}%"))
|
||||||
|
|
||||||
|
# Get total count before pagination
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
# Apply sorting
|
||||||
|
sort_column = valid_sort_fields[sort]
|
||||||
|
if order == "desc":
|
||||||
|
query = query.order_by(sort_column.desc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(sort_column.asc())
|
||||||
|
|
||||||
|
# Apply pagination
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
results = query.offset(offset).limit(limit).all()
|
||||||
|
|
||||||
|
# Get tags for each version's artifact
|
||||||
|
version_responses = []
|
||||||
|
for pkg_version, artifact in results:
|
||||||
|
# Get tags pointing to this artifact in this package
|
||||||
|
tags = (
|
||||||
|
db.query(Tag.name)
|
||||||
|
.filter(Tag.package_id == package.id, Tag.artifact_id == artifact.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
tag_names = [t[0] for t in tags]
|
||||||
|
|
||||||
|
version_responses.append(
|
||||||
|
PackageVersionResponse(
|
||||||
|
id=pkg_version.id,
|
||||||
|
package_id=pkg_version.package_id,
|
||||||
|
artifact_id=pkg_version.artifact_id,
|
||||||
|
version=pkg_version.version,
|
||||||
|
version_source=pkg_version.version_source,
|
||||||
|
created_at=pkg_version.created_at,
|
||||||
|
created_by=pkg_version.created_by,
|
||||||
|
size=artifact.size,
|
||||||
|
content_type=artifact.content_type,
|
||||||
|
original_name=artifact.original_name,
|
||||||
|
tags=tag_names,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
total_pages = math.ceil(total / limit) if total > 0 else 1
|
||||||
|
has_more = page < total_pages
|
||||||
|
|
||||||
|
return PaginatedResponse(
|
||||||
|
items=version_responses,
|
||||||
|
pagination=PaginationMeta(
|
||||||
|
page=page,
|
||||||
|
limit=limit,
|
||||||
|
total=total,
|
||||||
|
total_pages=total_pages,
|
||||||
|
has_more=has_more,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/project/{project_name}/{package_name}/versions/{version}",
|
||||||
|
response_model=PackageVersionDetailResponse,
|
||||||
|
)
|
||||||
|
def get_version(
|
||||||
|
project_name: str,
|
||||||
|
package_name: str,
|
||||||
|
version: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get details of a specific version."""
|
||||||
|
project = db.query(Project).filter(Project.name == project_name).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
pkg_version = (
|
||||||
|
db.query(PackageVersion)
|
||||||
|
.filter(PackageVersion.package_id == package.id, PackageVersion.version == version)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not pkg_version:
|
||||||
|
raise HTTPException(status_code=404, detail="Version not found")
|
||||||
|
|
||||||
|
artifact = db.query(Artifact).filter(Artifact.id == pkg_version.artifact_id).first()
|
||||||
|
|
||||||
|
# Get tags pointing to this artifact
|
||||||
|
tags = (
|
||||||
|
db.query(Tag.name)
|
||||||
|
.filter(Tag.package_id == package.id, Tag.artifact_id == artifact.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
tag_names = [t[0] for t in tags]
|
||||||
|
|
||||||
|
return PackageVersionDetailResponse(
|
||||||
|
id=pkg_version.id,
|
||||||
|
package_id=pkg_version.package_id,
|
||||||
|
artifact_id=pkg_version.artifact_id,
|
||||||
|
version=pkg_version.version,
|
||||||
|
version_source=pkg_version.version_source,
|
||||||
|
created_at=pkg_version.created_at,
|
||||||
|
created_by=pkg_version.created_by,
|
||||||
|
size=artifact.size,
|
||||||
|
content_type=artifact.content_type,
|
||||||
|
original_name=artifact.original_name,
|
||||||
|
tags=tag_names,
|
||||||
|
format_metadata=artifact.artifact_metadata,
|
||||||
|
checksum_md5=artifact.checksum_md5,
|
||||||
|
checksum_sha1=artifact.checksum_sha1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/api/v1/project/{project_name}/{package_name}/versions/{version}",
|
||||||
|
status_code=204,
|
||||||
|
)
|
||||||
|
def delete_version(
|
||||||
|
project_name: str,
|
||||||
|
package_name: str,
|
||||||
|
version: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Delete a version (admin only). Does not delete the underlying artifact."""
|
||||||
|
project = db.query(Project).filter(Project.name == project_name).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
pkg_version = (
|
||||||
|
db.query(PackageVersion)
|
||||||
|
.filter(PackageVersion.package_id == package.id, PackageVersion.version == version)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not pkg_version:
|
||||||
|
raise HTTPException(status_code=404, detail="Version not found")
|
||||||
|
|
||||||
|
artifact_id = pkg_version.artifact_id
|
||||||
|
|
||||||
|
# Delete version (triggers will decrement ref_count)
|
||||||
|
db.delete(pkg_version)
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
_log_audit(
|
||||||
|
db,
|
||||||
|
action="version.delete",
|
||||||
|
resource=f"project/{project_name}/{package_name}/version/{version}",
|
||||||
|
user_id=current_user.username,
|
||||||
|
source_ip=request.client.host if request.client else None,
|
||||||
|
details={"artifact_id": artifact_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
# Tag routes
|
# Tag routes
|
||||||
@router.get(
|
@router.get(
|
||||||
"/api/v1/project/{project_name}/{package_name}/tags",
|
"/api/v1/project/{project_name}/{package_name}/tags",
|
||||||
@@ -3224,10 +3594,17 @@ def list_tags(
|
|||||||
status_code=400, detail="Invalid order. Must be 'asc' or 'desc'"
|
status_code=400, detail="Invalid order. Must be 'asc' or 'desc'"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Base query with JOIN to artifact for metadata
|
# Base query with JOIN to artifact for metadata and LEFT JOIN to version
|
||||||
query = (
|
query = (
|
||||||
db.query(Tag, Artifact)
|
db.query(Tag, Artifact, PackageVersion.version)
|
||||||
.join(Artifact, Tag.artifact_id == Artifact.id)
|
.join(Artifact, Tag.artifact_id == Artifact.id)
|
||||||
|
.outerjoin(
|
||||||
|
PackageVersion,
|
||||||
|
and_(
|
||||||
|
PackageVersion.package_id == Tag.package_id,
|
||||||
|
PackageVersion.artifact_id == Tag.artifact_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
.filter(Tag.package_id == package.id)
|
.filter(Tag.package_id == package.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3264,9 +3641,9 @@ def list_tags(
|
|||||||
# Calculate total pages
|
# Calculate total pages
|
||||||
total_pages = math.ceil(total / limit) if total > 0 else 1
|
total_pages = math.ceil(total / limit) if total > 0 else 1
|
||||||
|
|
||||||
# Build detailed responses with artifact metadata
|
# Build detailed responses with artifact metadata and version
|
||||||
detailed_tags = []
|
detailed_tags = []
|
||||||
for tag, artifact in results:
|
for tag, artifact, version in results:
|
||||||
detailed_tags.append(
|
detailed_tags.append(
|
||||||
TagDetailResponse(
|
TagDetailResponse(
|
||||||
id=tag.id,
|
id=tag.id,
|
||||||
@@ -3280,6 +3657,7 @@ def list_tags(
|
|||||||
artifact_original_name=artifact.original_name,
|
artifact_original_name=artifact.original_name,
|
||||||
artifact_created_at=artifact.created_at,
|
artifact_created_at=artifact.created_at,
|
||||||
artifact_format_metadata=artifact.format_metadata,
|
artifact_format_metadata=artifact.format_metadata,
|
||||||
|
version=version,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3396,8 +3774,15 @@ def get_tag(
|
|||||||
raise HTTPException(status_code=404, detail="Package not found")
|
raise HTTPException(status_code=404, detail="Package not found")
|
||||||
|
|
||||||
result = (
|
result = (
|
||||||
db.query(Tag, Artifact)
|
db.query(Tag, Artifact, PackageVersion.version)
|
||||||
.join(Artifact, Tag.artifact_id == Artifact.id)
|
.join(Artifact, Tag.artifact_id == Artifact.id)
|
||||||
|
.outerjoin(
|
||||||
|
PackageVersion,
|
||||||
|
and_(
|
||||||
|
PackageVersion.package_id == Tag.package_id,
|
||||||
|
PackageVersion.artifact_id == Tag.artifact_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
.filter(Tag.package_id == package.id, Tag.name == tag_name)
|
.filter(Tag.package_id == package.id, Tag.name == tag_name)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
@@ -3405,7 +3790,7 @@ def get_tag(
|
|||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Tag not found")
|
raise HTTPException(status_code=404, detail="Tag not found")
|
||||||
|
|
||||||
tag, artifact = result
|
tag, artifact, version = result
|
||||||
return TagDetailResponse(
|
return TagDetailResponse(
|
||||||
id=tag.id,
|
id=tag.id,
|
||||||
package_id=tag.package_id,
|
package_id=tag.package_id,
|
||||||
@@ -3418,6 +3803,7 @@ def get_tag(
|
|||||||
artifact_original_name=artifact.original_name,
|
artifact_original_name=artifact.original_name,
|
||||||
artifact_created_at=artifact.created_at,
|
artifact_created_at=artifact.created_at,
|
||||||
artifact_format_metadata=artifact.format_metadata,
|
artifact_format_metadata=artifact.format_metadata,
|
||||||
|
version=version,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -3915,10 +4301,17 @@ def list_all_tags(
|
|||||||
List all tags globally with filtering by project, package, name, etc.
|
List all tags globally with filtering by project, package, name, etc.
|
||||||
"""
|
"""
|
||||||
query = (
|
query = (
|
||||||
db.query(Tag, Package, Project, Artifact)
|
db.query(Tag, Package, Project, Artifact, PackageVersion.version)
|
||||||
.join(Package, Tag.package_id == Package.id)
|
.join(Package, Tag.package_id == Package.id)
|
||||||
.join(Project, Package.project_id == Project.id)
|
.join(Project, Package.project_id == Project.id)
|
||||||
.join(Artifact, Tag.artifact_id == Artifact.id)
|
.join(Artifact, Tag.artifact_id == Artifact.id)
|
||||||
|
.outerjoin(
|
||||||
|
PackageVersion,
|
||||||
|
and_(
|
||||||
|
PackageVersion.package_id == Tag.package_id,
|
||||||
|
PackageVersion.artifact_id == Tag.artifact_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
@@ -3982,8 +4375,9 @@ def list_all_tags(
|
|||||||
package_name=pkg.name,
|
package_name=pkg.name,
|
||||||
artifact_size=artifact.size,
|
artifact_size=artifact.size,
|
||||||
artifact_content_type=artifact.content_type,
|
artifact_content_type=artifact.content_type,
|
||||||
|
version=version,
|
||||||
)
|
)
|
||||||
for tag, pkg, proj, artifact in results
|
for tag, pkg, proj, artifact, version in results
|
||||||
]
|
]
|
||||||
|
|
||||||
return PaginatedResponse(
|
return PaginatedResponse(
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ class TagResponse(BaseModel):
|
|||||||
artifact_id: str
|
artifact_id: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
created_by: str
|
created_by: str
|
||||||
|
version: Optional[str] = None # Version of the artifact this tag points to
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -187,6 +188,7 @@ class TagDetailResponse(BaseModel):
|
|||||||
artifact_id: str
|
artifact_id: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
created_by: str
|
created_by: str
|
||||||
|
version: Optional[str] = None # Version of the artifact this tag points to
|
||||||
# Artifact metadata
|
# Artifact metadata
|
||||||
artifact_size: int
|
artifact_size: int
|
||||||
artifact_content_type: Optional[str]
|
artifact_content_type: Optional[str]
|
||||||
@@ -383,6 +385,7 @@ class GlobalTagResponse(BaseModel):
|
|||||||
package_name: str
|
package_name: str
|
||||||
artifact_size: Optional[int] = None
|
artifact_size: Optional[int] = None
|
||||||
artifact_content_type: Optional[str] = None
|
artifact_content_type: Optional[str] = None
|
||||||
|
version: Optional[str] = None # Version of the artifact this tag points to
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -396,6 +399,8 @@ class UploadResponse(BaseModel):
|
|||||||
project: str
|
project: str
|
||||||
package: str
|
package: str
|
||||||
tag: Optional[str]
|
tag: Optional[str]
|
||||||
|
version: Optional[str] = None # Version assigned to this artifact
|
||||||
|
version_source: Optional[str] = None # How version was determined: 'explicit', 'filename', 'metadata'
|
||||||
checksum_md5: Optional[str] = None
|
checksum_md5: Optional[str] = None
|
||||||
checksum_sha1: Optional[str] = None
|
checksum_sha1: Optional[str] = None
|
||||||
s3_etag: Optional[str] = None
|
s3_etag: Optional[str] = None
|
||||||
@@ -418,6 +423,7 @@ class ResumableUploadInitRequest(BaseModel):
|
|||||||
content_type: Optional[str] = None
|
content_type: Optional[str] = None
|
||||||
size: int
|
size: int
|
||||||
tag: Optional[str] = None
|
tag: Optional[str] = None
|
||||||
|
version: Optional[str] = None # Explicit version (auto-detected if not provided)
|
||||||
|
|
||||||
@field_validator("expected_hash")
|
@field_validator("expected_hash")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -484,6 +490,35 @@ class ConsumerResponse(BaseModel):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# Package version schemas
|
||||||
|
class PackageVersionResponse(BaseModel):
|
||||||
|
"""Immutable version record for an artifact in a package"""
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
package_id: UUID
|
||||||
|
artifact_id: str
|
||||||
|
version: str
|
||||||
|
version_source: Optional[str] = None # 'explicit', 'filename', 'metadata', 'migrated_from_tag'
|
||||||
|
created_at: datetime
|
||||||
|
created_by: str
|
||||||
|
# Enriched fields from joins
|
||||||
|
size: Optional[int] = None
|
||||||
|
content_type: Optional[str] = None
|
||||||
|
original_name: Optional[str] = None
|
||||||
|
tags: List[str] = [] # Tag names pointing to this artifact
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class PackageVersionDetailResponse(PackageVersionResponse):
|
||||||
|
"""Version with full artifact metadata"""
|
||||||
|
|
||||||
|
format_metadata: Optional[Dict[str, Any]] = None
|
||||||
|
checksum_md5: Optional[str] = None
|
||||||
|
checksum_sha1: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# Global search schemas
|
# Global search schemas
|
||||||
class SearchResultProject(BaseModel):
|
class SearchResultProject(BaseModel):
|
||||||
"""Project result for global search"""
|
"""Project result for global search"""
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import hashlib
|
|||||||
import logging
|
import logging
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .models import Project, Package, Artifact, Tag, Upload
|
from .models import Project, Package, Artifact, Tag, Upload, PackageVersion
|
||||||
from .storage import get_storage
|
from .storage import get_storage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -74,7 +74,7 @@ TEST_PROJECTS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Sample artifacts to create (content, tags)
|
# Sample artifacts to create (content, tags, version)
|
||||||
TEST_ARTIFACTS = [
|
TEST_ARTIFACTS = [
|
||||||
{
|
{
|
||||||
"project": "frontend-libs",
|
"project": "frontend-libs",
|
||||||
@@ -83,6 +83,7 @@ TEST_ARTIFACTS = [
|
|||||||
"filename": "ui-components-1.0.0.js",
|
"filename": "ui-components-1.0.0.js",
|
||||||
"content_type": "application/javascript",
|
"content_type": "application/javascript",
|
||||||
"tags": ["v1.0.0", "latest"],
|
"tags": ["v1.0.0", "latest"],
|
||||||
|
"version": "1.0.0",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"project": "frontend-libs",
|
"project": "frontend-libs",
|
||||||
@@ -91,6 +92,7 @@ TEST_ARTIFACTS = [
|
|||||||
"filename": "ui-components-1.1.0.js",
|
"filename": "ui-components-1.1.0.js",
|
||||||
"content_type": "application/javascript",
|
"content_type": "application/javascript",
|
||||||
"tags": ["v1.1.0"],
|
"tags": ["v1.1.0"],
|
||||||
|
"version": "1.1.0",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"project": "frontend-libs",
|
"project": "frontend-libs",
|
||||||
@@ -99,6 +101,7 @@ TEST_ARTIFACTS = [
|
|||||||
"filename": "tokens.json",
|
"filename": "tokens.json",
|
||||||
"content_type": "application/json",
|
"content_type": "application/json",
|
||||||
"tags": ["v1.0.0", "latest"],
|
"tags": ["v1.0.0", "latest"],
|
||||||
|
"version": "1.0.0",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"project": "backend-services",
|
"project": "backend-services",
|
||||||
@@ -107,6 +110,7 @@ TEST_ARTIFACTS = [
|
|||||||
"filename": "utils-2.0.0.py",
|
"filename": "utils-2.0.0.py",
|
||||||
"content_type": "text/x-python",
|
"content_type": "text/x-python",
|
||||||
"tags": ["v2.0.0", "stable", "latest"],
|
"tags": ["v2.0.0", "stable", "latest"],
|
||||||
|
"version": "2.0.0",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"project": "backend-services",
|
"project": "backend-services",
|
||||||
@@ -115,6 +119,7 @@ TEST_ARTIFACTS = [
|
|||||||
"filename": "auth-lib-1.0.0.go",
|
"filename": "auth-lib-1.0.0.go",
|
||||||
"content_type": "text/x-go",
|
"content_type": "text/x-go",
|
||||||
"tags": ["v1.0.0", "latest"],
|
"tags": ["v1.0.0", "latest"],
|
||||||
|
"version": "1.0.0",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -160,9 +165,10 @@ def seed_database(db: Session) -> None:
|
|||||||
|
|
||||||
logger.info(f"Created {len(project_map)} projects and {len(package_map)} packages")
|
logger.info(f"Created {len(project_map)} projects and {len(package_map)} packages")
|
||||||
|
|
||||||
# Create artifacts and tags
|
# Create artifacts, tags, and versions
|
||||||
artifact_count = 0
|
artifact_count = 0
|
||||||
tag_count = 0
|
tag_count = 0
|
||||||
|
version_count = 0
|
||||||
|
|
||||||
for artifact_data in TEST_ARTIFACTS:
|
for artifact_data in TEST_ARTIFACTS:
|
||||||
project = project_map[artifact_data["project"]]
|
project = project_map[artifact_data["project"]]
|
||||||
@@ -184,6 +190,11 @@ def seed_database(db: Session) -> None:
|
|||||||
logger.warning(f"Failed to store artifact in S3: {e}")
|
logger.warning(f"Failed to store artifact in S3: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Calculate ref_count: tags + version (if present)
|
||||||
|
ref_count = len(artifact_data["tags"])
|
||||||
|
if artifact_data.get("version"):
|
||||||
|
ref_count += 1
|
||||||
|
|
||||||
# Create artifact record
|
# Create artifact record
|
||||||
artifact = Artifact(
|
artifact = Artifact(
|
||||||
id=sha256_hash,
|
id=sha256_hash,
|
||||||
@@ -192,7 +203,7 @@ def seed_database(db: Session) -> None:
|
|||||||
original_name=artifact_data["filename"],
|
original_name=artifact_data["filename"],
|
||||||
created_by="seed-user",
|
created_by="seed-user",
|
||||||
s3_key=s3_key,
|
s3_key=s3_key,
|
||||||
ref_count=len(artifact_data["tags"]),
|
ref_count=ref_count,
|
||||||
)
|
)
|
||||||
db.add(artifact)
|
db.add(artifact)
|
||||||
|
|
||||||
@@ -206,6 +217,18 @@ def seed_database(db: Session) -> None:
|
|||||||
db.add(upload)
|
db.add(upload)
|
||||||
artifact_count += 1
|
artifact_count += 1
|
||||||
|
|
||||||
|
# Create version record if specified
|
||||||
|
if artifact_data.get("version"):
|
||||||
|
version = PackageVersion(
|
||||||
|
package_id=package.id,
|
||||||
|
artifact_id=sha256_hash,
|
||||||
|
version=artifact_data["version"],
|
||||||
|
version_source="explicit",
|
||||||
|
created_by="seed-user",
|
||||||
|
)
|
||||||
|
db.add(version)
|
||||||
|
version_count += 1
|
||||||
|
|
||||||
# Create tags
|
# Create tags
|
||||||
for tag_name in artifact_data["tags"]:
|
for tag_name in artifact_data["tags"]:
|
||||||
tag = Tag(
|
tag = Tag(
|
||||||
@@ -218,5 +241,5 @@ def seed_database(db: Session) -> None:
|
|||||||
tag_count += 1
|
tag_count += 1
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info(f"Created {artifact_count} artifacts and {tag_count} tags")
|
logger.info(f"Created {artifact_count} artifacts, {tag_count} tags, and {version_count} versions")
|
||||||
logger.info("Database seeding complete")
|
logger.info("Database seeding complete")
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ def upload_test_file(
|
|||||||
content: bytes,
|
content: bytes,
|
||||||
filename: str = "test.bin",
|
filename: str = "test.bin",
|
||||||
tag: Optional[str] = None,
|
tag: Optional[str] = None,
|
||||||
|
version: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Helper function to upload a test file via the API.
|
Helper function to upload a test file via the API.
|
||||||
@@ -108,6 +109,7 @@ def upload_test_file(
|
|||||||
content: File content as bytes
|
content: File content as bytes
|
||||||
filename: Original filename
|
filename: Original filename
|
||||||
tag: Optional tag to assign
|
tag: Optional tag to assign
|
||||||
|
version: Optional version to assign
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The upload response as a dict
|
The upload response as a dict
|
||||||
@@ -116,6 +118,8 @@ def upload_test_file(
|
|||||||
data = {}
|
data = {}
|
||||||
if tag:
|
if tag:
|
||||||
data["tag"] = tag
|
data["tag"] = tag
|
||||||
|
if version:
|
||||||
|
data["version"] = version
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1/project/{project}/{package}/upload",
|
f"/api/v1/project/{project}/{package}/upload",
|
||||||
|
|||||||
412
backend/tests/integration/test_versions_api.py
Normal file
412
backend/tests/integration/test_versions_api.py
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for version API endpoints.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- Version creation via upload
|
||||||
|
- Version auto-detection from filename
|
||||||
|
- Version listing with pagination
|
||||||
|
- Version deletion
|
||||||
|
- Download by version ref
|
||||||
|
- ref_count behavior with version operations
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from tests.factories import upload_test_file
|
||||||
|
|
||||||
|
|
||||||
|
class TestVersionCreation:
|
||||||
|
"""Tests for version creation during upload."""
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_upload_with_explicit_version(self, integration_client, test_package):
|
||||||
|
"""Test creating a version via explicit version parameter."""
|
||||||
|
project_name, package_name = test_package
|
||||||
|
|
||||||
|
result = upload_test_file(
|
||||||
|
integration_client,
|
||||||
|
project_name,
|
||||||
|
package_name,
|
||||||
|
b"version create test",
|
||||||
|
tag="latest",
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["tag"] == "latest"
|
||||||
|
assert result["version"] == "1.0.0"
|
||||||
|
assert result["version_source"] == "explicit"
|
||||||
|
assert result["artifact_id"]
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_upload_with_version_auto_detect_from_tarball(
|
||||||
|
self, integration_client, test_package
|
||||||
|
):
|
||||||
|
"""Test version auto-detection from tarball filename pattern."""
|
||||||
|
project_name, package_name = test_package
|
||||||
|
|
||||||
|
result = upload_test_file(
|
||||||
|
integration_client,
|
||||||
|
project_name,
|
||||||
|
package_name,
|
||||||
|
b"auto version test",
|
||||||
|
filename="myapp-2.1.0.tar.gz",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["version"] == "2.1.0"
|
||||||
|
# Tarball metadata extractor parses version from filename
|
||||||
|
assert result["version_source"] == "metadata"
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_upload_with_version_auto_detect_v_prefix(
|
||||||
|
self, integration_client, test_package
|
||||||
|
):
|
||||||
|
"""Test version auto-detection strips 'v' prefix from tarball filename."""
|
||||||
|
project_name, package_name = test_package
|
||||||
|
|
||||||
|
result = upload_test_file(
|
||||||
|
integration_client,
|
||||||
|
project_name,
|
||||||
|
package_name,
|
||||||
|
b"v prefix test",
|
||||||
|
filename="package-v3.0.0.tar.gz",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["version"] == "3.0.0"
|
||||||
|
# Tarball metadata extractor parses version from filename
|
||||||
|
assert result["version_source"] == "metadata"
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_upload_duplicate_version_warning(self, integration_client, test_package):
|
||||||
|
"""Test that duplicate version during upload returns response without error."""
|
||||||
|
project_name, package_name = test_package
|
||||||
|
|
||||||
|
# Upload with version 1.0.0
|
||||||
|
upload_test_file(
|
||||||
|
integration_client,
|
||||||
|
project_name,
|
||||||
|
package_name,
|
||||||
|
b"first upload",
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upload different content with same version - should succeed but no new version
|
||||||
|
result = upload_test_file(
|
||||||
|
integration_client,
|
||||||
|
project_name,
|
||||||
|
package_name,
|
||||||
|
b"second upload different content",
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upload succeeds but version may not be set (duplicate)
|
||||||
|
assert result["artifact_id"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestVersionCRUD:
|
||||||
|
"""Tests for version list, get, delete operations."""
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_list_versions(self, integration_client, test_package):
|
||||||
|
"""Test listing versions for a package."""
|
||||||
|
project_name, package_name = test_package
|
||||||
|
|
||||||
|
# Create some versions
|
||||||
|
upload_test_file(
|
||||||
|
integration_client,
|
||||||
|
project_name,
|
||||||
|
package_name,
|
||||||
|
b"v1 content",
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
upload_test_file(
|
||||||
|
integration_client,
|
||||||
|
project_name,
|
||||||
|
package_name,
|
||||||
|
b"v2 content",
|
||||||
|
version="2.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = integration_client.get(
|
||||||
|
f"/api/v1/project/{project_name}/{package_name}/versions"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
assert "items" in data
|
||||||
|
assert "pagination" in data
|
||||||
|
|
||||||
|
versions = [v["version"] for v in data["items"]]
|
||||||
|
assert "1.0.0" in versions
|
||||||
|
assert "2.0.0" in versions
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_list_versions_with_artifact_info(self, integration_client, test_package):
|
||||||
|
"""Test that version list includes artifact metadata."""
|
||||||
|
project_name, package_name = test_package
|
||||||
|
|
||||||
|
upload_test_file(
|
||||||
|
integration_client,
|
||||||
|
project_name,
|
||||||
|
package_name,
|
||||||
|
b"version with info",
|
||||||
|
version="1.0.0",
|
||||||
|
tag="release",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = integration_client.get(
|
||||||
|
f"/api/v1/project/{project_name}/{package_name}/versions"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["items"]) >= 1
|
||||||
|
|
||||||
|
version_item = next(
|
||||||
|
(v for v in data["items"] if v["version"] == "1.0.0"), None
|
||||||
|
)
|
||||||
|
assert version_item is not None
|
||||||
|
assert "size" in version_item
|
||||||
|
assert "artifact_id" in version_item
|
||||||
|
assert "tags" in version_item
|
||||||
|
assert "release" in version_item["tags"]
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_get_version(self, integration_client, test_package):
|
||||||
|
"""Test getting a specific version."""
|
||||||
|
project_name, package_name = test_package
|
||||||
|
|
||||||
|
upload_result = upload_test_file(
|
||||||
|
integration_client,
|
||||||
|
project_name,
|
||||||
|
package_name,
|
||||||
|
b"get version test",
|
||||||
|
version="3.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = integration_client.get(
|
||||||
|
f"/api/v1/project/{project_name}/{package_name}/versions/3.0.0"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
assert data["version"] == "3.0.0"
|
||||||
|
assert data["artifact_id"] == upload_result["artifact_id"]
|
||||||
|
assert data["version_source"] == "explicit"
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_get_version_not_found(self, integration_client, test_package):
|
||||||
|
"""Test getting a non-existent version returns 404."""
|
||||||
|
project_name, package_name = test_package
|
||||||
|
|
||||||
|
response = integration_client.get(
|
||||||
|
f"/api/v1/project/{project_name}/{package_name}/versions/99.99.99"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_delete_version(self, integration_client, test_package):
|
||||||
|
"""Test deleting a version."""
|
||||||
|
project_name, package_name = test_package
|
||||||
|
|
||||||
|
upload_test_file(
|
||||||
|
integration_client,
|
||||||
|
project_name,
|
||||||
|
package_name,
|
||||||
|
b"delete version test",
|
||||||
|
version="4.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete version
|
||||||
|
response = integration_client.delete(
|
||||||
|
f"/api/v1/project/{project_name}/{package_name}/versions/4.0.0"
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
# Verify deleted
|
||||||
|
response = integration_client.get(
|
||||||
|
f"/api/v1/project/{project_name}/{package_name}/versions/4.0.0"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestVersionDownload:
|
||||||
|
"""Tests for downloading artifacts by version reference."""
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_download_by_version_prefix(self, integration_client, test_package):
|
||||||
|
"""Test downloading an artifact using version: prefix."""
|
||||||
|
project_name, package_name = test_package
|
||||||
|
content = b"download by version test"
|
||||||
|
|
||||||
|
upload_test_file(
|
||||||
|
integration_client,
|
||||||
|
project_name,
|
||||||
|
package_name,
|
||||||
|
content,
|
||||||
|
version="5.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = integration_client.get(
|
||||||
|
f"/api/v1/project/{project_name}/{package_name}/+/version:5.0.0",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should either redirect or return content
|
||||||
|
assert response.status_code in [200, 302, 307]
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_download_by_implicit_version(self, integration_client, test_package):
|
||||||
|
"""Test downloading an artifact using version number directly (no prefix)."""
|
||||||
|
project_name, package_name = test_package
|
||||||
|
content = b"implicit version download test"
|
||||||
|
|
||||||
|
upload_test_file(
|
||||||
|
integration_client,
|
||||||
|
project_name,
|
||||||
|
package_name,
|
||||||
|
content,
|
||||||
|
version="6.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = integration_client.get(
|
||||||
|
f"/api/v1/project/{project_name}/{package_name}/+/6.0.0",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should resolve version first (before tag)
|
||||||
|
assert response.status_code in [200, 302, 307]
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_version_takes_precedence_over_tag(self, integration_client, test_package):
|
||||||
|
"""Test that version is checked before tag when resolving refs."""
|
||||||
|
project_name, package_name = test_package
|
||||||
|
|
||||||
|
# Upload with version "1.0"
|
||||||
|
version_result = upload_test_file(
|
||||||
|
integration_client,
|
||||||
|
project_name,
|
||||||
|
package_name,
|
||||||
|
b"version content",
|
||||||
|
version="1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a tag with the same name "1.0" pointing to different artifact
|
||||||
|
tag_result = upload_test_file(
|
||||||
|
integration_client,
|
||||||
|
project_name,
|
||||||
|
package_name,
|
||||||
|
b"tag content different",
|
||||||
|
tag="1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Download by "1.0" should resolve to version, not tag
|
||||||
|
# Since version:1.0 artifact was uploaded first
|
||||||
|
response = integration_client.get(
|
||||||
|
f"/api/v1/project/{project_name}/{package_name}/+/1.0",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code in [200, 302, 307]
|
||||||
|
|
||||||
|
|
||||||
|
class TestTagVersionEnrichment:
|
||||||
|
"""Tests for tag responses including version information."""
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_tag_response_includes_version(self, integration_client, test_package):
|
||||||
|
"""Test that tag responses include version of the artifact."""
|
||||||
|
project_name, package_name = test_package
|
||||||
|
|
||||||
|
# Upload with both version and tag
|
||||||
|
upload_test_file(
|
||||||
|
integration_client,
|
||||||
|
project_name,
|
||||||
|
package_name,
|
||||||
|
b"enriched tag test",
|
||||||
|
version="7.0.0",
|
||||||
|
tag="stable",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get tag and check version field
|
||||||
|
response = integration_client.get(
|
||||||
|
f"/api/v1/project/{project_name}/{package_name}/tags/stable"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "stable"
|
||||||
|
assert data["version"] == "7.0.0"
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_tag_list_includes_versions(self, integration_client, test_package):
|
||||||
|
"""Test that tag list responses include version for each tag."""
|
||||||
|
project_name, package_name = test_package
|
||||||
|
|
||||||
|
upload_test_file(
|
||||||
|
integration_client,
|
||||||
|
project_name,
|
||||||
|
package_name,
|
||||||
|
b"list version test",
|
||||||
|
version="8.0.0",
|
||||||
|
tag="latest",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = integration_client.get(
|
||||||
|
f"/api/v1/project/{project_name}/{package_name}/tags"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
tag_item = next((t for t in data["items"] if t["name"] == "latest"), None)
|
||||||
|
assert tag_item is not None
|
||||||
|
assert tag_item.get("version") == "8.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
class TestVersionPagination:
|
||||||
|
"""Tests for version listing pagination and sorting."""
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_versions_pagination(self, integration_client, test_package):
|
||||||
|
"""Test version listing respects pagination."""
|
||||||
|
project_name, package_name = test_package
|
||||||
|
|
||||||
|
response = integration_client.get(
|
||||||
|
f"/api/v1/project/{project_name}/{package_name}/versions?limit=5"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
assert "pagination" in data
|
||||||
|
assert data["pagination"]["limit"] == 5
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_versions_sorting(self, integration_client, test_package):
|
||||||
|
"""Test version listing can be sorted."""
|
||||||
|
project_name, package_name = test_package
|
||||||
|
|
||||||
|
# Create versions with different timestamps
|
||||||
|
upload_test_file(
|
||||||
|
integration_client,
|
||||||
|
project_name,
|
||||||
|
package_name,
|
||||||
|
b"sort test 1",
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
upload_test_file(
|
||||||
|
integration_client,
|
||||||
|
project_name,
|
||||||
|
package_name,
|
||||||
|
b"sort test 2",
|
||||||
|
version="2.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test ascending sort
|
||||||
|
response = integration_client.get(
|
||||||
|
f"/api/v1/project/{project_name}/{package_name}/versions?sort=version&order=asc"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
versions = [v["version"] for v in data["items"]]
|
||||||
|
# First version should be 1.0.0 when sorted ascending
|
||||||
|
if len(versions) >= 2:
|
||||||
|
assert versions.index("1.0.0") < versions.index("2.0.0")
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
OIDCConfig,
|
OIDCConfig,
|
||||||
OIDCConfigUpdate,
|
OIDCConfigUpdate,
|
||||||
OIDCStatus,
|
OIDCStatus,
|
||||||
|
PackageVersion,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const API_BASE = '/api/v1';
|
const API_BASE = '/api/v1';
|
||||||
@@ -239,12 +240,21 @@ export async function listPackageArtifacts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Upload
|
// Upload
|
||||||
export async function uploadArtifact(projectName: string, packageName: string, file: File, tag?: string): Promise<UploadResponse> {
|
export async function uploadArtifact(
|
||||||
|
projectName: string,
|
||||||
|
packageName: string,
|
||||||
|
file: File,
|
||||||
|
tag?: string,
|
||||||
|
version?: string
|
||||||
|
): Promise<UploadResponse> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
if (tag) {
|
if (tag) {
|
||||||
formData.append('tag', tag);
|
formData.append('tag', tag);
|
||||||
}
|
}
|
||||||
|
if (version) {
|
||||||
|
formData.append('version', version);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/upload`, {
|
const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/upload`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -443,3 +453,38 @@ export function getOIDCLoginUrl(returnTo?: string): string {
|
|||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
return `${API_BASE}/auth/oidc/login${query ? `?${query}` : ''}`;
|
return `${API_BASE}/auth/oidc/login${query ? `?${query}` : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Version API
|
||||||
|
export async function listVersions(
|
||||||
|
projectName: string,
|
||||||
|
packageName: string,
|
||||||
|
params: ListParams = {}
|
||||||
|
): Promise<PaginatedResponse<PackageVersion>> {
|
||||||
|
const query = buildQueryString(params as Record<string, unknown>);
|
||||||
|
const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/versions${query}`);
|
||||||
|
return handleResponse<PaginatedResponse<PackageVersion>>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVersion(
|
||||||
|
projectName: string,
|
||||||
|
packageName: string,
|
||||||
|
version: string
|
||||||
|
): Promise<PackageVersion> {
|
||||||
|
const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/versions/${version}`);
|
||||||
|
return handleResponse<PackageVersion>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteVersion(
|
||||||
|
projectName: string,
|
||||||
|
packageName: string,
|
||||||
|
version: string
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/versions/${version}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||||
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -324,6 +324,86 @@ tr:hover .copy-btn {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Version badge */
|
||||||
|
.version-badge {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Create Tag Section */
|
||||||
|
.create-tag-section {
|
||||||
|
margin-top: 32px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-tag-section h3 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-tag-form .form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-tag-form .form-group {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-tag-form .form-group--wide {
|
||||||
|
flex: 2;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-tag-form .form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-tag-form .form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-tag-form .form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-tag-form .form-group input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-tag-form button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Created cell */
|
/* Created cell */
|
||||||
.created-cell {
|
.created-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { TagDetail, Package, PaginatedResponse, AccessLevel } from '../types';
|
import { TagDetail, Package, PaginatedResponse, AccessLevel } from '../types';
|
||||||
import { listTags, getDownloadUrl, getPackage, getMyProjectAccess, UnauthorizedError, ForbiddenError } from '../api';
|
import { listTags, getDownloadUrl, getPackage, getMyProjectAccess, createTag, UnauthorizedError, ForbiddenError } from '../api';
|
||||||
import { Breadcrumb } from '../components/Breadcrumb';
|
import { Breadcrumb } from '../components/Breadcrumb';
|
||||||
import { Badge } from '../components/Badge';
|
import { Badge } from '../components/Badge';
|
||||||
import { SearchInput } from '../components/SearchInput';
|
import { SearchInput } from '../components/SearchInput';
|
||||||
@@ -64,6 +64,9 @@ function PackagePage() {
|
|||||||
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
|
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
|
||||||
const [artifactIdInput, setArtifactIdInput] = useState('');
|
const [artifactIdInput, setArtifactIdInput] = useState('');
|
||||||
const [accessLevel, setAccessLevel] = useState<AccessLevel | null>(null);
|
const [accessLevel, setAccessLevel] = useState<AccessLevel | null>(null);
|
||||||
|
const [createTagName, setCreateTagName] = useState('');
|
||||||
|
const [createTagArtifactId, setCreateTagArtifactId] = useState('');
|
||||||
|
const [createTagLoading, setCreateTagLoading] = useState(false);
|
||||||
|
|
||||||
// Derived permissions
|
// Derived permissions
|
||||||
const canWrite = accessLevel === 'write' || accessLevel === 'admin';
|
const canWrite = accessLevel === 'write' || accessLevel === 'admin';
|
||||||
@@ -154,6 +157,30 @@ function PackagePage() {
|
|||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateTag = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!createTagName.trim() || createTagArtifactId.length !== 64) return;
|
||||||
|
|
||||||
|
setCreateTagLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createTag(projectName!, packageName!, {
|
||||||
|
name: createTagName.trim(),
|
||||||
|
artifact_id: createTagArtifactId,
|
||||||
|
});
|
||||||
|
setUploadSuccess(`Tag "${createTagName}" created successfully!`);
|
||||||
|
setCreateTagName('');
|
||||||
|
setCreateTagArtifactId('');
|
||||||
|
loadData();
|
||||||
|
setTimeout(() => setUploadSuccess(null), 5000);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create tag');
|
||||||
|
} finally {
|
||||||
|
setCreateTagLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
const handleSearchChange = (value: string) => {
|
||||||
updateParams({ search: value, page: '1' });
|
updateParams({ search: value, page: '1' });
|
||||||
};
|
};
|
||||||
@@ -182,6 +209,13 @@ function PackagePage() {
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
render: (t: TagDetail) => <strong>{t.name}</strong>,
|
render: (t: TagDetail) => <strong>{t.name}</strong>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'version',
|
||||||
|
header: 'Version',
|
||||||
|
render: (t: TagDetail) => (
|
||||||
|
<span className="version-badge">{t.version || '-'}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'artifact_id',
|
key: 'artifact_id',
|
||||||
header: 'Artifact ID',
|
header: 'Artifact ID',
|
||||||
@@ -433,6 +467,50 @@ function PackagePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{user && canWrite && (
|
||||||
|
<div className="create-tag-section card">
|
||||||
|
<h3>Create / Update Tag</h3>
|
||||||
|
<p className="section-description">Point a tag at any existing artifact by its ID</p>
|
||||||
|
<form onSubmit={handleCreateTag} className="create-tag-form">
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="create-tag-name">Tag Name</label>
|
||||||
|
<input
|
||||||
|
id="create-tag-name"
|
||||||
|
type="text"
|
||||||
|
value={createTagName}
|
||||||
|
onChange={(e) => setCreateTagName(e.target.value)}
|
||||||
|
placeholder="latest, stable, v1.0.0..."
|
||||||
|
disabled={createTagLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group form-group--wide">
|
||||||
|
<label htmlFor="create-tag-artifact">Artifact ID</label>
|
||||||
|
<input
|
||||||
|
id="create-tag-artifact"
|
||||||
|
type="text"
|
||||||
|
value={createTagArtifactId}
|
||||||
|
onChange={(e) => setCreateTagArtifactId(e.target.value.toLowerCase().replace(/[^a-f0-9]/g, '').slice(0, 64))}
|
||||||
|
placeholder="SHA256 hash (64 hex characters)"
|
||||||
|
className="artifact-id-input"
|
||||||
|
disabled={createTagLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={createTagLoading || !createTagName.trim() || createTagArtifactId.length !== 64}
|
||||||
|
>
|
||||||
|
{createTagLoading ? 'Creating...' : 'Create Tag'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{createTagArtifactId.length > 0 && createTagArtifactId.length !== 64 && (
|
||||||
|
<p className="validation-hint">Artifact ID must be exactly 64 hex characters ({createTagArtifactId.length}/64)</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="usage-section card">
|
<div className="usage-section card">
|
||||||
<h3>Usage</h3>
|
<h3>Usage</h3>
|
||||||
<p>Download artifacts using:</p>
|
<p>Download artifacts using:</p>
|
||||||
|
|||||||
@@ -63,6 +63,22 @@ export interface TagDetail extends Tag {
|
|||||||
artifact_original_name: string | null;
|
artifact_original_name: string | null;
|
||||||
artifact_created_at: string;
|
artifact_created_at: string;
|
||||||
artifact_format_metadata: Record<string, unknown> | null;
|
artifact_format_metadata: Record<string, unknown> | null;
|
||||||
|
version: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackageVersion {
|
||||||
|
id: string;
|
||||||
|
package_id: string;
|
||||||
|
artifact_id: string;
|
||||||
|
version: string;
|
||||||
|
version_source: string | null;
|
||||||
|
created_at: string;
|
||||||
|
created_by: string;
|
||||||
|
// Enriched fields from joins
|
||||||
|
size?: number;
|
||||||
|
content_type?: string | null;
|
||||||
|
original_name?: string | null;
|
||||||
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArtifactTagInfo {
|
export interface ArtifactTagInfo {
|
||||||
@@ -122,6 +138,8 @@ export interface UploadResponse {
|
|||||||
project: string;
|
project: string;
|
||||||
package: string;
|
package: string;
|
||||||
tag: string | null;
|
tag: string | null;
|
||||||
|
version: string | null;
|
||||||
|
version_source: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global search types
|
// Global search types
|
||||||
|
|||||||
67
migrations/007_package_versions.sql
Normal file
67
migrations/007_package_versions.sql
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
-- Migration: Add package_versions table for separate version tracking
|
||||||
|
-- This separates immutable versions from mutable tags
|
||||||
|
|
||||||
|
-- Create package_versions table
|
||||||
|
CREATE TABLE IF NOT EXISTS package_versions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
package_id UUID NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
|
||||||
|
artifact_id VARCHAR(64) NOT NULL REFERENCES artifacts(id),
|
||||||
|
version VARCHAR(255) NOT NULL,
|
||||||
|
version_source VARCHAR(50), -- 'explicit', 'filename', 'metadata', 'migrated_from_tag'
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
created_by VARCHAR(255) NOT NULL,
|
||||||
|
UNIQUE(package_id, version),
|
||||||
|
UNIQUE(package_id, artifact_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for common queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_package_versions_package_id ON package_versions(package_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_package_versions_artifact_id ON package_versions(artifact_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_package_versions_package_version ON package_versions(package_id, version);
|
||||||
|
|
||||||
|
-- Trigger functions for ref_count management (same pattern as tags)
|
||||||
|
CREATE OR REPLACE FUNCTION increment_version_ref_count()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE artifacts SET ref_count = ref_count + 1 WHERE id = NEW.artifact_id;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION decrement_version_ref_count()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE artifacts SET ref_count = ref_count - 1 WHERE id = OLD.artifact_id;
|
||||||
|
RETURN OLD;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Create triggers
|
||||||
|
DROP TRIGGER IF EXISTS package_versions_ref_count_insert ON package_versions;
|
||||||
|
CREATE TRIGGER package_versions_ref_count_insert
|
||||||
|
AFTER INSERT ON package_versions
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION increment_version_ref_count();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS package_versions_ref_count_delete ON package_versions;
|
||||||
|
CREATE TRIGGER package_versions_ref_count_delete
|
||||||
|
AFTER DELETE ON package_versions
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION decrement_version_ref_count();
|
||||||
|
|
||||||
|
-- Data migration: populate from existing semver-pattern tags
|
||||||
|
-- This extracts versions from tags that look like version numbers
|
||||||
|
-- Tags like "v1.0.0", "1.2.3", "2.0.0-beta" will be migrated
|
||||||
|
-- Tags like "latest", "stable", "dev" will NOT be migrated
|
||||||
|
INSERT INTO package_versions (package_id, artifact_id, version, version_source, created_by, created_at)
|
||||||
|
SELECT
|
||||||
|
t.package_id,
|
||||||
|
t.artifact_id,
|
||||||
|
-- Strip leading 'v' if present
|
||||||
|
CASE WHEN t.name LIKE 'v%' THEN substring(t.name from 2) ELSE t.name END,
|
||||||
|
'migrated_from_tag',
|
||||||
|
t.created_by,
|
||||||
|
t.created_at
|
||||||
|
FROM tags t
|
||||||
|
WHERE t.name ~ '^v?[0-9]+\.[0-9]+(\.[0-9]+)?([-.][a-zA-Z0-9]+)?$'
|
||||||
|
ON CONFLICT (package_id, version) DO NOTHING;
|
||||||
Reference in New Issue
Block a user