12 Commits

Author SHA1 Message Date
Mondo Diaz
20e5a2948e Fix httpx.Timeout configuration in PyPI proxy 2026-01-29 16:39:13 -06:00
Mondo Diaz
b4e23d9899 Fix PyPI proxy tests to use ORCHARD_TEST_URL env var directly 2026-01-29 16:00:56 -06:00
Mondo Diaz
aa3bd05d46 Fix PyPI proxy tests to use unauthenticated_client fixture 2026-01-29 15:51:29 -06:00
Mondo Diaz
810e024d09 Update CHANGELOG with issue #108 2026-01-29 15:41:56 -06:00
Mondo Diaz
9e3eea4d08 Add cache/resolve endpoint and reduce footer padding
- Add POST /api/v1/cache/resolve endpoint that caches artifacts by package
  coordinates (source_type, package, version) instead of requiring a URL
- Server finds download URL from configured upstream sources (PyPI supported)
- Reduces footer padding from 24px to 12px for cleaner layout
2026-01-29 15:33:19 -06:00
Mondo Diaz
a9de32d922 Add transparent PyPI proxy and improve upstream sources UI
- Implement PEP 503 Simple API endpoints for pip compatibility:
  - GET /pypi/simple/ - package index
  - GET /pypi/simple/{package}/ - version list with rewritten links
  - GET /pypi/simple/{package}/{filename} - download with auto-caching

- Improve upstream sources table UI:
  - Center text under column headers
  - Remove separate Source column, show ENV badge inline with name
  - Make Test/Edit buttons more prominent with secondary button style
2026-01-29 15:30:57 -06:00
Mondo Diaz
e8cf2462b7 Merge branch 'fix/upstream-caching-bugs-2' into 'main'
Simplify cache management UI and improve test status display (#107)

See merge request esv/bsf/bsf-integration/orchard/orchard-mvp!55
2026-01-29 14:25:19 -06:00
Mondo Diaz
038ad4ed1b Simplify cache management UI and improve test status display (#107) 2026-01-29 14:25:19 -06:00
Mondo Diaz
858b45d434 Merge branch 'fix/purge-seed-data-user-id' into 'main'
Fix purge_seed_data type mismatch for access_permissions.user_id (#107)

See merge request esv/bsf/bsf-integration/orchard/orchard-mvp!54
2026-01-29 13:48:21 -06:00
Mondo Diaz
95470b2bf6 Fix purge_seed_data type mismatch for access_permissions.user_id (#107) 2026-01-29 13:48:21 -06:00
Mondo Diaz
c512d85f9e Merge branch 'fix/upstream-caching-bugs' into 'main'
Remove public internet features and fix upstream source UI (#107)

See merge request esv/bsf/bsf-integration/orchard/orchard-mvp!53
2026-01-29 13:26:29 -06:00
Mondo Diaz
82f67539bd Remove public internet features and fix upstream source UI (#107) 2026-01-29 13:26:28 -06:00
19 changed files with 1131 additions and 512 deletions

View File

@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Added transparent PyPI proxy implementing PEP 503 Simple API (#108)
- `GET /pypi/simple/` - package index (proxied from upstream)
- `GET /pypi/simple/{package}/` - version list with rewritten download links
- `GET /pypi/simple/{package}/{filename}` - download with automatic caching
- Allows `pip install --index-url https://orchard.../pypi/simple/ <package>`
- Artifacts cached on first access through configured upstream sources
- Added `POST /api/v1/cache/resolve` endpoint to cache packages by coordinates instead of URL (#108)
### Changed
- Upstream sources table text is now centered under column headers (#108)
- ENV badge now appears inline with source name instead of separate column (#108)
- Test and Edit buttons now have more prominent button styling (#108)
- Reduced footer padding for cleaner layout (#108)
### Fixed
- Fixed purge_seed_data crash when deleting access permissions - was comparing UUID to VARCHAR column (#107)
### Changed
- Upstream source connectivity test no longer follows redirects, fixing "Exceeded maximum allowed redirects" error with Artifactory proxies (#107)
- Test runs automatically after saving a new or updated upstream source (#107)
- Test status now shows as colored dots (green=success, red=error) instead of text badges (#107)
- Clicking red dot shows error details in a modal (#107)
- Source name column no longer wraps text for better table layout (#107)
- Renamed "Cache Management" page to "Upstream Sources" (#107)
- Moved Delete button from table row to edit modal for cleaner table layout (#107)
### Removed
- Removed `is_public` field from upstream sources - all sources are now treated as internal/private (#107)
- Removed `allow_public_internet` (air-gap mode) setting from cache settings - not needed for enterprise proxy use case (#107)
- Removed seeding of public registry URLs (npm-public, pypi-public, maven-central, docker-hub) (#107)
- Removed "Public" badge and checkbox from upstream sources UI (#107)
- Removed "Allow Public Internet" toggle from cache settings UI (#107)
- Removed "Global Settings" section from cache management UI - auto-create system projects is always enabled (#107)
- Removed unused CacheSettings frontend types and API functions (#107)
### Added
- Added `ORCHARD_PURGE_SEED_DATA` environment variable support to stage helm values to remove seed data from long-running deployments (#107)
- Added frontend system projects visual distinction (#105)
- "Cache" badge for system projects in project list
- "System Cache" badge on project detail page

View File

@@ -61,8 +61,7 @@ class Settings(BaseSettings):
# Cache settings
cache_encryption_key: str = "" # Fernet key for encrypting upstream credentials (auto-generated if empty)
# Global cache settings overrides (None = use DB value, True/False = override DB)
cache_allow_public_internet: Optional[bool] = None # Override allow_public_internet (air-gap mode)
# Global cache settings override (None = use DB value, True/False = override DB)
cache_auto_create_system_projects: Optional[bool] = None # Override auto_create_system_projects
# JWT Authentication settings (optional, for external identity providers)
@@ -108,7 +107,6 @@ class EnvUpstreamSource:
url: str,
source_type: str = "generic",
enabled: bool = True,
is_public: bool = True,
auth_type: str = "none",
username: Optional[str] = None,
password: Optional[str] = None,
@@ -118,7 +116,6 @@ class EnvUpstreamSource:
self.url = url
self.source_type = source_type
self.enabled = enabled
self.is_public = is_public
self.auth_type = auth_type
self.username = username
self.password = password
@@ -188,7 +185,6 @@ def parse_upstream_sources_from_env() -> list[EnvUpstreamSource]:
url=url,
source_type=data.get("TYPE", "generic").lower(),
enabled=parse_bool(data.get("ENABLED"), True),
is_public=parse_bool(data.get("IS_PUBLIC"), True),
auth_type=data.get("AUTH_TYPE", "none").lower(),
username=data.get("USERNAME"),
password=data.get("PASSWORD"),

View File

@@ -462,7 +462,6 @@ def _run_migrations():
source_type VARCHAR(50) NOT NULL DEFAULT 'generic',
url VARCHAR(2048) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
is_public BOOLEAN NOT NULL DEFAULT TRUE,
auth_type VARCHAR(20) NOT NULL DEFAULT 'none',
username VARCHAR(255),
password_encrypted BYTEA,
@@ -480,7 +479,6 @@ def _run_migrations():
);
CREATE INDEX IF NOT EXISTS idx_upstream_sources_enabled ON upstream_sources(enabled);
CREATE INDEX IF NOT EXISTS idx_upstream_sources_source_type ON upstream_sources(source_type);
CREATE INDEX IF NOT EXISTS idx_upstream_sources_is_public ON upstream_sources(is_public);
CREATE INDEX IF NOT EXISTS idx_upstream_sources_priority ON upstream_sources(priority);
""",
),
@@ -489,14 +487,13 @@ def _run_migrations():
sql="""
CREATE TABLE IF NOT EXISTS cache_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
allow_public_internet BOOLEAN NOT NULL DEFAULT TRUE,
auto_create_system_projects BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT check_cache_settings_singleton CHECK (id = 1)
);
INSERT INTO cache_settings (id, allow_public_internet, auto_create_system_projects)
VALUES (1, TRUE, TRUE)
INSERT INTO cache_settings (id, auto_create_system_projects)
VALUES (1, TRUE)
ON CONFLICT (id) DO NOTHING;
""",
),
@@ -522,13 +519,50 @@ def _run_migrations():
Migration(
name="020_seed_default_upstream_sources",
sql="""
INSERT INTO upstream_sources (id, name, source_type, url, enabled, is_public, auth_type, priority)
VALUES
(gen_random_uuid(), 'npm-public', 'npm', 'https://registry.npmjs.org', FALSE, TRUE, 'none', 100),
(gen_random_uuid(), 'pypi-public', 'pypi', 'https://pypi.org/simple', FALSE, TRUE, 'none', 100),
(gen_random_uuid(), 'maven-central', 'maven', 'https://repo1.maven.org/maven2', FALSE, TRUE, 'none', 100),
(gen_random_uuid(), 'docker-hub', 'docker', 'https://registry-1.docker.io', FALSE, TRUE, 'none', 100)
ON CONFLICT (name) DO NOTHING;
-- Originally seeded public sources, but these are no longer used.
-- Migration 023 deletes any previously seeded sources.
-- This migration is now a no-op for fresh installs.
SELECT 1;
""",
),
Migration(
name="021_remove_is_public_from_upstream_sources",
sql="""
DO $$
BEGIN
-- Drop the index if it exists
DROP INDEX IF EXISTS idx_upstream_sources_is_public;
-- Drop the column if it exists
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'upstream_sources' AND column_name = 'is_public'
) THEN
ALTER TABLE upstream_sources DROP COLUMN is_public;
END IF;
END $$;
""",
),
Migration(
name="022_remove_allow_public_internet_from_cache_settings",
sql="""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'cache_settings' AND column_name = 'allow_public_internet'
) THEN
ALTER TABLE cache_settings DROP COLUMN allow_public_internet;
END IF;
END $$;
""",
),
Migration(
name="023_delete_seeded_public_sources",
sql="""
-- Delete the seeded public sources that were added by migration 020
DELETE FROM upstream_sources
WHERE name IN ('npm-public', 'pypi-public', 'maven-central', 'docker-hub');
""",
),
]

View File

@@ -11,6 +11,7 @@ from slowapi.errors import RateLimitExceeded
from .config import get_settings
from .database import init_db, SessionLocal
from .routes import router
from .pypi_proxy import router as pypi_router
from .seed import seed_database
from .auth import create_default_admin
from .rate_limit import limiter
@@ -65,6 +66,7 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Include API routes
app.include_router(router)
app.include_router(pypi_router)
# Serve static files (React build) if the directory exists
static_dir = os.path.join(os.path.dirname(__file__), "..", "..", "frontend", "dist")

View File

@@ -667,7 +667,6 @@ class UpstreamSource(Base):
source_type = Column(String(50), default="generic", nullable=False)
url = Column(String(2048), nullable=False)
enabled = Column(Boolean, default=False, nullable=False)
is_public = Column(Boolean, default=True, nullable=False)
auth_type = Column(String(20), default="none", nullable=False)
username = Column(String(255))
password_encrypted = Column(LargeBinary)
@@ -684,7 +683,6 @@ class UpstreamSource(Base):
__table_args__ = (
Index("idx_upstream_sources_enabled", "enabled"),
Index("idx_upstream_sources_source_type", "source_type"),
Index("idx_upstream_sources_is_public", "is_public"),
Index("idx_upstream_sources_priority", "priority"),
CheckConstraint(
"source_type IN ('npm', 'pypi', 'maven', 'docker', 'helm', 'nuget', 'deb', 'rpm', 'generic')",
@@ -747,13 +745,12 @@ class UpstreamSource(Base):
class CacheSettings(Base):
"""Global cache settings (singleton table).
Controls behavior of the upstream caching system including air-gap mode.
Controls behavior of the upstream caching system.
"""
__tablename__ = "cache_settings"
id = Column(Integer, primary_key=True, default=1)
allow_public_internet = Column(Boolean, default=True, nullable=False)
auto_create_system_projects = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
updated_at = Column(

View File

@@ -194,7 +194,8 @@ def purge_seed_data(db: Session) -> dict:
synchronize_session=False
)
# Delete any access permissions for this user
db.query(AccessPermission).filter(AccessPermission.user_id == user.id).delete(
# Note: AccessPermission.user_id is VARCHAR (username), not UUID
db.query(AccessPermission).filter(AccessPermission.user_id == user.username).delete(
synchronize_session=False
)
db.delete(user)

534
backend/app/pypi_proxy.py Normal file
View File

@@ -0,0 +1,534 @@
"""
Transparent PyPI proxy implementing PEP 503 (Simple API).
Provides endpoints that allow pip to use Orchard as a PyPI index URL.
Artifacts are cached on first access through configured upstream sources.
"""
import hashlib
import logging
import re
from typing import Optional
from urllib.parse import urljoin, urlparse, quote, unquote
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import StreamingResponse, HTMLResponse
from sqlalchemy.orm import Session
from .database import get_db
from .models import UpstreamSource, CachedUrl, Artifact, Project, Package, Tag
from .storage import S3Storage, get_storage
from .upstream import (
UpstreamClient,
UpstreamClientConfig,
UpstreamHTTPError,
UpstreamConnectionError,
UpstreamTimeoutError,
)
from .config import get_env_upstream_sources
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/pypi", tags=["pypi-proxy"])
# Timeout configuration for proxy requests
PROXY_CONNECT_TIMEOUT = 30.0
PROXY_READ_TIMEOUT = 60.0
def _get_pypi_upstream_sources(db: Session) -> list[UpstreamSource]:
"""Get all enabled upstream sources configured for PyPI."""
# Get database sources
db_sources = (
db.query(UpstreamSource)
.filter(
UpstreamSource.source_type == "pypi",
UpstreamSource.enabled == True,
)
.order_by(UpstreamSource.priority)
.all()
)
# Get env sources
env_sources = [
s for s in get_env_upstream_sources()
if s.source_type == "pypi" and s.enabled
]
# Combine and sort by priority
all_sources = list(db_sources) + list(env_sources)
return sorted(all_sources, key=lambda s: s.priority)
def _build_auth_headers(source) -> dict:
"""Build authentication headers for an upstream source."""
headers = {}
if hasattr(source, 'auth_type'):
if source.auth_type == "bearer":
password = source.get_password() if hasattr(source, 'get_password') else getattr(source, 'password', None)
if password:
headers["Authorization"] = f"Bearer {password}"
elif source.auth_type == "api_key":
custom_headers = source.get_headers() if hasattr(source, 'get_headers') else {}
if custom_headers:
headers.update(custom_headers)
return headers
def _get_basic_auth(source) -> Optional[tuple[str, str]]:
"""Get basic auth credentials if applicable."""
if hasattr(source, 'auth_type') and source.auth_type == "basic":
username = getattr(source, 'username', None)
if username:
password = source.get_password() if hasattr(source, 'get_password') else getattr(source, 'password', '')
return (username, password or '')
return None
def _rewrite_package_links(html: str, base_url: str, package_name: str) -> str:
"""
Rewrite download links in a PyPI simple page to go through our proxy.
Args:
html: The HTML content from upstream
base_url: Our server's base URL
package_name: The package name for the URL path
Returns:
HTML with rewritten download links
"""
# Pattern to match href attributes in anchor tags
# PyPI simple pages have links like:
# <a href="https://files.pythonhosted.org/packages/.../file.tar.gz#sha256=...">file.tar.gz</a>
def replace_href(match):
original_url = match.group(1)
# Extract the filename from the URL
parsed = urlparse(original_url)
path_parts = parsed.path.split('/')
filename = path_parts[-1] if path_parts else ''
# Keep the hash fragment if present
fragment = f"#{parsed.fragment}" if parsed.fragment else ""
# Encode the original URL for safe transmission
encoded_url = quote(original_url.split('#')[0], safe='')
# Build new URL pointing to our proxy
new_url = f"{base_url}/pypi/simple/{package_name}/{filename}?upstream={encoded_url}{fragment}"
return f'href="{new_url}"'
# Match href="..." patterns
rewritten = re.sub(r'href="([^"]+)"', replace_href, html)
return rewritten
@router.get("/simple/")
async def pypi_simple_index(
request: Request,
db: Session = Depends(get_db),
):
"""
PyPI Simple API index - lists all packages.
Proxies to the first available upstream PyPI source.
"""
sources = _get_pypi_upstream_sources(db)
if not sources:
raise HTTPException(
status_code=503,
detail="No PyPI upstream sources configured"
)
# Try each source in priority order
last_error = None
for source in sources:
try:
headers = {"User-Agent": "Orchard-PyPI-Proxy/1.0"}
headers.update(_build_auth_headers(source))
auth = _get_basic_auth(source)
simple_url = source.url.rstrip('/') + '/simple/'
timeout = httpx.Timeout(PROXY_READ_TIMEOUT, connect=PROXY_CONNECT_TIMEOUT)
with httpx.Client(timeout=timeout, follow_redirects=False) as client:
response = client.get(
simple_url,
headers=headers,
auth=auth,
)
# Handle redirects manually to avoid loops
if response.status_code in (301, 302, 303, 307, 308):
redirect_url = response.headers.get('location')
if redirect_url:
# Follow the redirect once
response = client.get(
redirect_url,
headers=headers,
auth=auth,
follow_redirects=False,
)
if response.status_code == 200:
# Return the index as-is (links are to package pages, not files)
# We could rewrite these too, but for now just proxy
content = response.text
# Rewrite package links to go through our proxy
base_url = str(request.base_url).rstrip('/')
content = re.sub(
r'href="([^"]+)/"',
lambda m: f'href="{base_url}/pypi/simple/{m.group(1)}/"',
content
)
return HTMLResponse(content=content)
last_error = f"HTTP {response.status_code}"
except httpx.ConnectError as e:
last_error = f"Connection failed: {e}"
logger.warning(f"PyPI proxy: failed to connect to {source.url}: {e}")
except httpx.TimeoutException as e:
last_error = f"Timeout: {e}"
logger.warning(f"PyPI proxy: timeout connecting to {source.url}: {e}")
except Exception as e:
last_error = str(e)
logger.warning(f"PyPI proxy: error fetching from {source.url}: {e}")
raise HTTPException(
status_code=502,
detail=f"Failed to fetch package index from upstream: {last_error}"
)
@router.get("/simple/{package_name}/")
async def pypi_package_versions(
request: Request,
package_name: str,
db: Session = Depends(get_db),
):
"""
PyPI Simple API package page - lists all versions/files for a package.
Proxies to upstream and rewrites download links to go through our cache.
"""
sources = _get_pypi_upstream_sources(db)
if not sources:
raise HTTPException(
status_code=503,
detail="No PyPI upstream sources configured"
)
base_url = str(request.base_url).rstrip('/')
# Normalize package name (PEP 503)
normalized_name = re.sub(r'[-_.]+', '-', package_name).lower()
# Try each source in priority order
last_error = None
for source in sources:
try:
headers = {"User-Agent": "Orchard-PyPI-Proxy/1.0"}
headers.update(_build_auth_headers(source))
auth = _get_basic_auth(source)
package_url = source.url.rstrip('/') + f'/simple/{normalized_name}/'
timeout = httpx.Timeout(PROXY_READ_TIMEOUT, connect=PROXY_CONNECT_TIMEOUT)
with httpx.Client(timeout=timeout, follow_redirects=False) as client:
response = client.get(
package_url,
headers=headers,
auth=auth,
)
# Handle redirects manually
redirect_count = 0
while response.status_code in (301, 302, 303, 307, 308) and redirect_count < 5:
redirect_url = response.headers.get('location')
if not redirect_url:
break
# Make redirect URL absolute if needed
if not redirect_url.startswith('http'):
redirect_url = urljoin(package_url, redirect_url)
response = client.get(
redirect_url,
headers=headers,
auth=auth,
follow_redirects=False,
)
redirect_count += 1
if response.status_code == 200:
content = response.text
# Rewrite download links to go through our proxy
content = _rewrite_package_links(content, base_url, normalized_name)
return HTMLResponse(content=content)
if response.status_code == 404:
# Package not found in this source, try next
last_error = f"Package not found in {source.name}"
continue
last_error = f"HTTP {response.status_code}"
except httpx.ConnectError as e:
last_error = f"Connection failed: {e}"
logger.warning(f"PyPI proxy: failed to connect to {source.url}: {e}")
except httpx.TimeoutException as e:
last_error = f"Timeout: {e}"
logger.warning(f"PyPI proxy: timeout connecting to {source.url}: {e}")
except Exception as e:
last_error = str(e)
logger.warning(f"PyPI proxy: error fetching {package_name} from {source.url}: {e}")
raise HTTPException(
status_code=404,
detail=f"Package '{package_name}' not found: {last_error}"
)
@router.get("/simple/{package_name}/{filename}")
async def pypi_download_file(
request: Request,
package_name: str,
filename: str,
upstream: Optional[str] = None,
db: Session = Depends(get_db),
storage: S3Storage = Depends(get_storage),
):
"""
Download a package file, caching it in Orchard.
Args:
package_name: The package name
filename: The filename to download
upstream: URL-encoded upstream URL to fetch from
"""
if not upstream:
raise HTTPException(
status_code=400,
detail="Missing 'upstream' query parameter with source URL"
)
# Decode the upstream URL
upstream_url = unquote(upstream)
# Check if we already have this URL cached
url_hash = hashlib.sha256(upstream_url.encode()).hexdigest()
cached_url = db.query(CachedUrl).filter(CachedUrl.url_hash == url_hash).first()
if cached_url:
# Serve from cache
artifact = db.query(Artifact).filter(Artifact.id == cached_url.artifact_id).first()
if artifact:
logger.info(f"PyPI proxy: serving cached {filename} (artifact {artifact.id[:12]})")
# Stream from S3
try:
content_stream = storage.get_artifact_stream(artifact.id)
return StreamingResponse(
content_stream,
media_type=artifact.content_type or "application/octet-stream",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Length": str(artifact.size),
"X-Checksum-SHA256": artifact.id,
"X-Cache": "HIT",
}
)
except Exception as e:
logger.error(f"PyPI proxy: error streaming cached artifact: {e}")
# Fall through to fetch from upstream
# Not cached - fetch from upstream
sources = _get_pypi_upstream_sources(db)
# Find a source that matches the upstream URL
matched_source = None
for source in sources:
source_url = getattr(source, 'url', '')
# Check if the upstream URL could come from this source
# (This is a loose check - the URL might be from files.pythonhosted.org)
if urlparse(upstream_url).netloc in source_url or True: # Allow any source for now
matched_source = source
break
if not matched_source and sources:
matched_source = sources[0] # Use first source for auth if available
try:
headers = {"User-Agent": "Orchard-PyPI-Proxy/1.0"}
if matched_source:
headers.update(_build_auth_headers(matched_source))
auth = _get_basic_auth(matched_source) if matched_source else None
timeout = httpx.Timeout(300.0, connect=PROXY_CONNECT_TIMEOUT) # 5 minutes for large files
# Fetch the file
logger.info(f"PyPI proxy: fetching {filename} from {upstream_url}")
with httpx.Client(timeout=timeout, follow_redirects=False) as client:
response = client.get(
upstream_url,
headers=headers,
auth=auth,
)
# Handle redirects manually
redirect_count = 0
while response.status_code in (301, 302, 303, 307, 308) and redirect_count < 5:
redirect_url = response.headers.get('location')
if not redirect_url:
break
if not redirect_url.startswith('http'):
redirect_url = urljoin(upstream_url, redirect_url)
logger.info(f"PyPI proxy: following redirect to {redirect_url}")
# Don't send auth to different hosts
redirect_headers = {"User-Agent": "Orchard-PyPI-Proxy/1.0"}
redirect_auth = None
if urlparse(redirect_url).netloc == urlparse(upstream_url).netloc:
redirect_headers.update(headers)
redirect_auth = auth
response = client.get(
redirect_url,
headers=redirect_headers,
auth=redirect_auth,
follow_redirects=False,
)
redirect_count += 1
if response.status_code != 200:
raise HTTPException(
status_code=response.status_code,
detail=f"Upstream returned {response.status_code}"
)
content = response.content
content_type = response.headers.get('content-type', 'application/octet-stream')
# Compute hash
sha256 = hashlib.sha256(content).hexdigest()
size = len(content)
logger.info(f"PyPI proxy: downloaded {filename}, {size} bytes, sha256={sha256[:12]}")
# Store in S3
from io import BytesIO
artifact = storage.store_artifact(
file_obj=BytesIO(content),
filename=filename,
content_type=content_type,
)
# Check if artifact already exists
existing = db.query(Artifact).filter(Artifact.id == sha256).first()
if existing:
# Increment ref count
existing.ref_count += 1
db.flush()
else:
# Create artifact record
new_artifact = Artifact(
id=sha256,
filename=filename,
content_type=content_type,
size=size,
ref_count=1,
)
db.add(new_artifact)
db.flush()
# Create/get system project and package
system_project = db.query(Project).filter(Project.name == "_pypi").first()
if not system_project:
system_project = Project(
name="_pypi",
description="System project for cached PyPI packages",
visibility="private",
)
db.add(system_project)
db.flush()
# Normalize package name
normalized_name = re.sub(r'[-_.]+', '-', package_name).lower()
package = db.query(Package).filter(
Package.project_id == system_project.id,
Package.name == normalized_name,
).first()
if not package:
package = Package(
project_id=system_project.id,
name=normalized_name,
description=f"PyPI package: {normalized_name}",
)
db.add(package)
db.flush()
# Create tag with filename
existing_tag = db.query(Tag).filter(
Tag.package_id == package.id,
Tag.name == filename,
).first()
if not existing_tag:
tag = Tag(
package_id=package.id,
name=filename,
artifact_id=sha256,
)
db.add(tag)
# Cache the URL mapping
existing_cached = db.query(CachedUrl).filter(CachedUrl.url_hash == url_hash).first()
if not existing_cached:
cached_url_record = CachedUrl(
url_hash=url_hash,
url=upstream_url,
artifact_id=sha256,
)
db.add(cached_url_record)
db.commit()
# Return the file
return Response(
content=content,
media_type=content_type,
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Length": str(size),
"X-Checksum-SHA256": sha256,
"X-Cache": "MISS",
}
)
except httpx.ConnectError as e:
raise HTTPException(status_code=502, detail=f"Connection failed: {e}")
except httpx.TimeoutException as e:
raise HTTPException(status_code=504, detail=f"Timeout: {e}")
except HTTPException:
raise
except Exception as e:
logger.exception(f"PyPI proxy: error downloading {filename}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -7866,7 +7866,6 @@ from .upstream import (
UpstreamTimeoutError,
UpstreamHTTPError,
UpstreamSSLError,
AirGapError,
FileSizeExceededError as UpstreamFileSizeExceededError,
SourceNotFoundError,
SourceDisabledError,
@@ -8021,10 +8020,6 @@ def cache_artifact(
- Optionally creates tag in user project
- Records URL mapping for provenance
**Air-Gap Mode:**
When `allow_public_internet` is false, only URLs matching private
(non-public) upstream sources are allowed.
**Example (curl):**
```bash
curl -X POST "http://localhost:8080/api/v1/cache" \\
@@ -8118,8 +8113,6 @@ def cache_artifact(
cache_request.url,
expected_hash=cache_request.expected_hash,
)
except AirGapError as e:
raise HTTPException(status_code=403, detail=str(e))
except SourceDisabledError as e:
raise HTTPException(status_code=503, detail=str(e))
except UpstreamHTTPError as e:
@@ -8312,6 +8305,200 @@ def _create_user_cache_reference(
return f"{user_project_name}/{user_package_name}"
# --- Cache Resolve Endpoint ---
from .schemas import CacheResolveRequest
@router.post(
"/api/v1/cache/resolve",
response_model=CacheResponse,
tags=["cache"],
summary="Cache an artifact by package coordinates",
)
def cache_resolve(
request: Request,
resolve_request: CacheResolveRequest,
db: Session = Depends(get_db),
storage: S3Storage = Depends(get_storage),
current_user: User = Depends(get_current_user),
):
"""
Cache an artifact by package coordinates (no URL required).
The server finds the appropriate download URL based on source_type
and configured upstream sources. Currently supports PyPI packages.
**Request Body:**
- `source_type` (required): Type of source (pypi, npm, maven, etc.)
- `package` (required): Package name
- `version` (required): Package version
- `user_project` (optional): Also create reference in this user project
- `user_package` (optional): Package name in user project
- `user_tag` (optional): Tag name in user project
**Example (curl):**
```bash
curl -X POST "http://localhost:8080/api/v1/cache/resolve" \\
-H "Authorization: Bearer <api-key>" \\
-H "Content-Type: application/json" \\
-d '{
"source_type": "pypi",
"package": "requests",
"version": "2.31.0"
}'
```
"""
import re
import httpx
from urllib.parse import quote, unquote
if resolve_request.source_type != "pypi":
raise HTTPException(
status_code=501,
detail=f"Cache resolve for '{resolve_request.source_type}' not yet implemented. Currently only 'pypi' is supported."
)
# Get PyPI upstream sources
sources = (
db.query(UpstreamSource)
.filter(
UpstreamSource.source_type == "pypi",
UpstreamSource.enabled == True,
)
.order_by(UpstreamSource.priority)
.all()
)
# Also get env sources
env_sources = [
s for s in get_env_upstream_sources()
if s.source_type == "pypi" and s.enabled
]
all_sources = list(sources) + list(env_sources)
all_sources = sorted(all_sources, key=lambda s: s.priority)
if not all_sources:
raise HTTPException(
status_code=503,
detail="No PyPI upstream sources configured"
)
# Normalize package name (PEP 503)
normalized_package = re.sub(r'[-_.]+', '-', resolve_request.package).lower()
# Query the Simple API to find the download URL
download_url = None
matched_filename = None
last_error = None
for source in all_sources:
try:
headers = {"User-Agent": "Orchard-CacheResolver/1.0"}
# Build auth if needed
if hasattr(source, 'auth_type'):
if source.auth_type == "bearer":
password = source.get_password() if hasattr(source, 'get_password') else getattr(source, 'password', None)
if password:
headers["Authorization"] = f"Bearer {password}"
elif source.auth_type == "api_key":
custom_headers = source.get_headers() if hasattr(source, 'get_headers') else {}
if custom_headers:
headers.update(custom_headers)
auth = None
if hasattr(source, 'auth_type') and source.auth_type == "basic":
username = getattr(source, 'username', None)
if username:
password = source.get_password() if hasattr(source, 'get_password') else getattr(source, 'password', '')
auth = (username, password or '')
source_url = getattr(source, 'url', '')
package_url = source_url.rstrip('/') + f'/simple/{normalized_package}/'
timeout = httpx.Timeout(connect=30.0, read=60.0)
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
response = client.get(package_url, headers=headers, auth=auth)
if response.status_code == 404:
last_error = f"Package not found in {getattr(source, 'name', 'source')}"
continue
if response.status_code != 200:
last_error = f"HTTP {response.status_code} from {getattr(source, 'name', 'source')}"
continue
# Parse HTML to find the version
html = response.text
# Look for links containing the version
# Pattern: href="...{package}-{version}...#sha256=..."
version_pattern = re.escape(resolve_request.version)
link_pattern = rf'href="([^"]+{normalized_package}[^"]*{version_pattern}[^"]*)"'
matches = re.findall(link_pattern, html, re.IGNORECASE)
if not matches:
# Try with original package name
link_pattern = rf'href="([^"]+{re.escape(resolve_request.package)}[^"]*{version_pattern}[^"]*)"'
matches = re.findall(link_pattern, html, re.IGNORECASE)
if matches:
# Prefer .tar.gz or .whl files
for match in matches:
url = match.split('#')[0] # Remove hash fragment
if url.endswith('.tar.gz') or url.endswith('.whl'):
download_url = url
# Extract filename
matched_filename = url.split('/')[-1]
break
if not download_url:
# Use first match
download_url = matches[0].split('#')[0]
matched_filename = download_url.split('/')[-1]
break
last_error = f"Version {resolve_request.version} not found for {resolve_request.package}"
except httpx.ConnectError as e:
last_error = f"Connection failed: {e}"
logger.warning(f"Cache resolve: failed to connect to {getattr(source, 'url', 'source')}: {e}")
except httpx.TimeoutException as e:
last_error = f"Timeout: {e}"
logger.warning(f"Cache resolve: timeout connecting to {getattr(source, 'url', 'source')}: {e}")
except Exception as e:
last_error = str(e)
logger.warning(f"Cache resolve: error: {e}")
if not download_url:
raise HTTPException(
status_code=404,
detail=f"Could not find {resolve_request.package}=={resolve_request.version}: {last_error}"
)
# Now cache the artifact using the existing cache_artifact logic
# Construct a CacheRequest
cache_request = CacheRequest(
url=download_url,
source_type="pypi",
package_name=normalized_package,
tag=matched_filename or resolve_request.version,
user_project=resolve_request.user_project,
user_package=resolve_request.user_package,
user_tag=resolve_request.user_tag,
)
# Call the cache logic
return cache_artifact(
request=request,
cache_request=cache_request,
db=db,
storage=storage,
current_user=current_user,
)
# --- Upstream Sources Admin API ---
from .schemas import (
@@ -8333,7 +8520,6 @@ def _env_source_to_response(env_source) -> UpstreamSourceResponse:
source_type=env_source.source_type,
url=env_source.url,
enabled=env_source.enabled,
is_public=env_source.is_public,
auth_type=env_source.auth_type,
username=env_source.username,
has_password=bool(env_source.password),
@@ -8417,7 +8603,6 @@ def list_upstream_sources(
source_type=s.source_type,
url=s.url,
enabled=s.enabled,
is_public=s.is_public,
auth_type=s.auth_type,
username=s.username,
has_password=s.has_password(),
@@ -8466,7 +8651,6 @@ def create_upstream_source(
"source_type": "npm",
"url": "https://npm.internal.corp",
"enabled": true,
"is_public": false,
"auth_type": "basic",
"username": "reader",
"password": "secret123",
@@ -8488,7 +8672,6 @@ def create_upstream_source(
source_type=source_create.source_type,
url=source_create.url,
enabled=source_create.enabled,
is_public=source_create.is_public,
auth_type=source_create.auth_type,
username=source_create.username,
priority=source_create.priority,
@@ -8528,7 +8711,6 @@ def create_upstream_source(
source_type=source.source_type,
url=source.url,
enabled=source.enabled,
is_public=source.is_public,
auth_type=source.auth_type,
username=source.username,
has_password=source.has_password(),
@@ -8576,7 +8758,6 @@ def get_upstream_source(
source_type=source.source_type,
url=source.url,
enabled=source.enabled,
is_public=source.is_public,
auth_type=source.auth_type,
username=source.username,
has_password=source.has_password(),
@@ -8663,10 +8844,6 @@ def update_upstream_source(
changes["enabled"] = {"old": source.enabled, "new": source_update.enabled}
source.enabled = source_update.enabled
if source_update.is_public is not None and source_update.is_public != source.is_public:
changes["is_public"] = {"old": source.is_public, "new": source_update.is_public}
source.is_public = source_update.is_public
if source_update.auth_type is not None and source_update.auth_type != source.auth_type:
changes["auth_type"] = {"old": source.auth_type, "new": source_update.auth_type}
source.auth_type = source_update.auth_type
@@ -8719,7 +8896,6 @@ def update_upstream_source(
source_type=source.source_type,
url=source.url,
enabled=source.enabled,
is_public=source.is_public,
auth_type=source.auth_type,
username=source.username,
has_password=source.has_password(),
@@ -8860,12 +9036,10 @@ def get_cache_settings(
Admin-only endpoint for viewing cache configuration.
**Settings:**
- `allow_public_internet`: When false, blocks all requests to sources marked `is_public=true` (air-gap mode)
- `auto_create_system_projects`: When true, system projects (`_npm`, etc.) are created automatically on first cache
**Environment variable overrides:**
Settings can be overridden via environment variables:
- `ORCHARD_CACHE_ALLOW_PUBLIC_INTERNET`: Overrides `allow_public_internet`
- `ORCHARD_CACHE_AUTO_CREATE_SYSTEM_PROJECTS`: Overrides `auto_create_system_projects`
When an env var override is active, the `*_env_override` field will contain the override value.
@@ -8874,12 +9048,6 @@ def get_cache_settings(
db_settings = _get_cache_settings(db)
# Apply env var overrides
allow_public_internet = db_settings.allow_public_internet
allow_public_internet_env_override = None
if app_settings.cache_allow_public_internet is not None:
allow_public_internet = app_settings.cache_allow_public_internet
allow_public_internet_env_override = app_settings.cache_allow_public_internet
auto_create_system_projects = db_settings.auto_create_system_projects
auto_create_system_projects_env_override = None
if app_settings.cache_auto_create_system_projects is not None:
@@ -8887,9 +9055,7 @@ def get_cache_settings(
auto_create_system_projects_env_override = app_settings.cache_auto_create_system_projects
return CacheSettingsResponse(
allow_public_internet=allow_public_internet,
auto_create_system_projects=auto_create_system_projects,
allow_public_internet_env_override=allow_public_internet_env_override,
auto_create_system_projects_env_override=auto_create_system_projects_env_override,
created_at=db_settings.created_at,
updated_at=db_settings.updated_at,
@@ -8915,16 +9081,11 @@ def update_cache_settings(
Supports partial updates - only provided fields are updated.
**Settings:**
- `allow_public_internet`: When false, enables air-gap mode (blocks public sources)
- `auto_create_system_projects`: When false, system projects must be created manually
**Note:** Environment variables can override these settings. When overridden,
the `*_env_override` fields in the response indicate the effective value.
Updates to the database will be saved but won't take effect until the env var is removed.
**Warning:** Changing `allow_public_internet` to false will immediately block
all cache requests to public sources. This is a security-sensitive setting
and is logged prominently.
"""
app_settings = get_settings()
settings = _get_cache_settings(db)
@@ -8932,26 +9093,6 @@ def update_cache_settings(
# Track changes for audit log
changes = {}
if settings_update.allow_public_internet is not None:
if settings_update.allow_public_internet != settings.allow_public_internet:
changes["allow_public_internet"] = {
"old": settings.allow_public_internet,
"new": settings_update.allow_public_internet,
}
settings.allow_public_internet = settings_update.allow_public_internet
# Log prominently for security audit
if not settings_update.allow_public_internet:
logger.warning(
f"AIR-GAP MODE ENABLED by {current_user.username} - "
f"all public internet access is now blocked"
)
else:
logger.warning(
f"AIR-GAP MODE DISABLED by {current_user.username} - "
f"public internet access is now allowed"
)
if settings_update.auto_create_system_projects is not None:
if settings_update.auto_create_system_projects != settings.auto_create_system_projects:
changes["auto_create_system_projects"] = {
@@ -8961,11 +9102,9 @@ def update_cache_settings(
settings.auto_create_system_projects = settings_update.auto_create_system_projects
if changes:
# Audit log with security flag for air-gap changes
is_security_change = "allow_public_internet" in changes
_log_audit(
db,
action="cache_settings.update" if not is_security_change else "cache_settings.security_update",
action="cache_settings.update",
resource="cache-settings",
user_id=current_user.username,
source_ip=request.client.host if request.client else None,
@@ -8976,12 +9115,6 @@ def update_cache_settings(
db.refresh(settings)
# Apply env var overrides for the response
allow_public_internet = settings.allow_public_internet
allow_public_internet_env_override = None
if app_settings.cache_allow_public_internet is not None:
allow_public_internet = app_settings.cache_allow_public_internet
allow_public_internet_env_override = app_settings.cache_allow_public_internet
auto_create_system_projects = settings.auto_create_system_projects
auto_create_system_projects_env_override = None
if app_settings.cache_auto_create_system_projects is not None:
@@ -8989,9 +9122,7 @@ def update_cache_settings(
auto_create_system_projects_env_override = app_settings.cache_auto_create_system_projects
return CacheSettingsResponse(
allow_public_internet=allow_public_internet,
auto_create_system_projects=auto_create_system_projects,
allow_public_internet_env_override=allow_public_internet_env_override,
auto_create_system_projects_env_override=auto_create_system_projects_env_override,
created_at=settings.created_at,
updated_at=settings.updated_at,

View File

@@ -1214,7 +1214,6 @@ class UpstreamSourceCreate(BaseModel):
source_type: str = "generic"
url: str
enabled: bool = False
is_public: bool = True
auth_type: str = "none"
username: Optional[str] = None
password: Optional[str] = None # Write-only
@@ -1271,7 +1270,6 @@ class UpstreamSourceUpdate(BaseModel):
source_type: Optional[str] = None
url: Optional[str] = None
enabled: Optional[bool] = None
is_public: Optional[bool] = None
auth_type: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None # Write-only, None = keep existing, empty string = clear
@@ -1331,7 +1329,6 @@ class UpstreamSourceResponse(BaseModel):
source_type: str
url: str
enabled: bool
is_public: bool
auth_type: str
username: Optional[str]
has_password: bool # True if password is set
@@ -1347,9 +1344,7 @@ class UpstreamSourceResponse(BaseModel):
class CacheSettingsResponse(BaseModel):
"""Global cache settings response"""
allow_public_internet: bool
auto_create_system_projects: bool
allow_public_internet_env_override: Optional[bool] = None # Set if overridden by env var
auto_create_system_projects_env_override: Optional[bool] = None # Set if overridden by env var
created_at: Optional[datetime] = None # May be None for legacy data
updated_at: Optional[datetime] = None # May be None for legacy data
@@ -1360,7 +1355,6 @@ class CacheSettingsResponse(BaseModel):
class CacheSettingsUpdate(BaseModel):
"""Update cache settings (partial)"""
allow_public_internet: Optional[bool] = None
auto_create_system_projects: Optional[bool] = None
@@ -1438,4 +1432,41 @@ class CacheResponse(BaseModel):
user_reference: Optional[str] = None # e.g., "my-app/npm-deps:lodash-4.17.21"
class CacheResolveRequest(BaseModel):
"""Request to cache an artifact by package coordinates (no URL required).
The server will construct the appropriate URL based on source_type and
configured upstream sources.
"""
source_type: str
package: str
version: str
user_project: Optional[str] = None
user_package: Optional[str] = None
user_tag: Optional[str] = None
@field_validator('source_type')
@classmethod
def validate_source_type(cls, v: str) -> str:
if v not in SOURCE_TYPES:
raise ValueError(f"source_type must be one of: {', '.join(SOURCE_TYPES)}")
return v
@field_validator('package')
@classmethod
def validate_package(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("package cannot be empty")
return v
@field_validator('version')
@classmethod
def validate_version(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("version cannot be empty")
return v

View File

@@ -57,10 +57,6 @@ class UpstreamSSLError(UpstreamError):
pass
class AirGapError(UpstreamError):
"""Request blocked due to air-gap mode."""
pass
class FileSizeExceededError(UpstreamError):
@@ -156,12 +152,6 @@ class UpstreamClient:
# Sort sources by priority (lower = higher priority)
self.sources = sorted(self.sources, key=lambda s: s.priority)
def _get_allow_public_internet(self) -> bool:
"""Get the allow_public_internet setting."""
if self.cache_settings is None:
return True # Default to allowing if no settings provided
return self.cache_settings.allow_public_internet
def _match_source(self, url: str) -> Optional[UpstreamSource]:
"""
Find the upstream source that matches the given URL.
@@ -288,7 +278,6 @@ class UpstreamClient:
FetchResult with content, hash, size, and headers.
Raises:
AirGapError: If air-gap mode blocks the request.
SourceDisabledError: If the matching source is disabled.
UpstreamConnectionError: On connection failures.
UpstreamTimeoutError: On timeout.
@@ -301,19 +290,6 @@ class UpstreamClient:
# Match URL to source
source = self._match_source(url)
# Check air-gap mode
allow_public = self._get_allow_public_internet()
if not allow_public:
if source is None:
raise AirGapError(
f"Air-gap mode enabled: URL does not match any configured upstream source: {url}"
)
if source.is_public:
raise AirGapError(
f"Air-gap mode enabled: Cannot fetch from public source '{source.name}'"
)
# Check if source is enabled (if we have a match)
if source is not None and not source.enabled:
raise SourceDisabledError(
@@ -536,7 +512,8 @@ class UpstreamClient:
Test connectivity to an upstream source.
Performs a HEAD request to the source URL to verify connectivity
and authentication.
and authentication. Does not follow redirects - a 3xx response
is considered successful since it proves the server is reachable.
Args:
source: The upstream source to test.
@@ -564,7 +541,7 @@ class UpstreamClient:
source.url,
headers=headers,
auth=auth,
follow_redirects=True,
follow_redirects=False,
)
# Consider 2xx and 3xx as success, also 405 (Method Not Allowed)
# since some servers don't support HEAD
@@ -582,5 +559,7 @@ class UpstreamClient:
return (False, f"Connection timed out: {e}", None)
except httpx.ReadTimeout as e:
return (False, f"Read timed out: {e}", None)
except httpx.TooManyRedirects as e:
return (False, f"Too many redirects: {e}", None)
except Exception as e:
return (False, f"Error: {e}", None)

View File

@@ -0,0 +1,93 @@
"""Integration tests for PyPI transparent proxy."""
import os
import pytest
import httpx
def get_base_url():
"""Get the base URL for the Orchard server from environment."""
return os.environ.get("ORCHARD_TEST_URL", "http://localhost:8080")
class TestPyPIProxyEndpoints:
"""Tests for PyPI proxy endpoints.
These endpoints are public (no auth required) since pip needs to use them.
"""
@pytest.mark.integration
def test_pypi_simple_index_no_sources(self):
"""Test that /pypi/simple/ returns 503 when no sources configured."""
with httpx.Client(base_url=get_base_url(), timeout=30.0) as client:
response = client.get("/pypi/simple/")
# Should return 503 when no PyPI upstream sources are configured
assert response.status_code == 503
assert "No PyPI upstream sources configured" in response.json()["detail"]
@pytest.mark.integration
def test_pypi_package_no_sources(self):
"""Test that /pypi/simple/{package}/ returns 503 when no sources configured."""
with httpx.Client(base_url=get_base_url(), timeout=30.0) as client:
response = client.get("/pypi/simple/requests/")
assert response.status_code == 503
assert "No PyPI upstream sources configured" in response.json()["detail"]
@pytest.mark.integration
def test_pypi_download_missing_upstream_param(self):
"""Test that /pypi/simple/{package}/{filename} requires upstream param."""
with httpx.Client(base_url=get_base_url(), timeout=30.0) as client:
response = client.get("/pypi/simple/requests/requests-2.31.0.tar.gz")
assert response.status_code == 400
assert "upstream" in response.json()["detail"].lower()
class TestPyPILinkRewriting:
"""Tests for URL rewriting in PyPI proxy responses."""
def test_rewrite_package_links(self):
"""Test that download links are rewritten to go through proxy."""
from app.pypi_proxy import _rewrite_package_links
html = '''
<html>
<body>
<a href="https://files.pythonhosted.org/packages/ab/cd/requests-2.31.0.tar.gz#sha256=abc123">requests-2.31.0.tar.gz</a>
<a href="https://files.pythonhosted.org/packages/ef/gh/requests-2.31.0-py3-none-any.whl#sha256=def456">requests-2.31.0-py3-none-any.whl</a>
</body>
</html>
'''
result = _rewrite_package_links(html, "http://localhost:8080", "requests")
# Links should be rewritten to go through our proxy
assert "/pypi/simple/requests/requests-2.31.0.tar.gz?upstream=" in result
assert "/pypi/simple/requests/requests-2.31.0-py3-none-any.whl?upstream=" in result
# Original URLs should be encoded in upstream param
assert "files.pythonhosted.org" in result
# Hash fragments should be preserved
assert "#sha256=abc123" in result
assert "#sha256=def456" in result
class TestPyPIPackageNormalization:
"""Tests for PyPI package name normalization."""
@pytest.mark.integration
def test_package_name_normalized(self):
"""Test that package names are normalized per PEP 503."""
# These should all be treated the same:
# requests, Requests, requests_, requests-
# The endpoint normalizes to lowercase with hyphens
with httpx.Client(base_url=get_base_url(), timeout=30.0) as client:
# Without upstream sources, we get 503, but the normalization
# happens before the source lookup
response = client.get("/pypi/simple/Requests/")
assert response.status_code == 503 # No sources, but path was valid
response = client.get("/pypi/simple/some_package/")
assert response.status_code == 503
response = client.get("/pypi/simple/some-package/")
assert response.status_code == 503

View File

@@ -91,7 +91,6 @@ class TestUpstreamSourceModel:
assert hasattr(source, 'source_type')
assert hasattr(source, 'url')
assert hasattr(source, 'enabled')
assert hasattr(source, 'is_public')
assert hasattr(source, 'auth_type')
assert hasattr(source, 'username')
assert hasattr(source, 'password_encrypted')
@@ -107,7 +106,6 @@ class TestUpstreamSourceModel:
source_type="npm",
url="https://npm.example.com",
enabled=True,
is_public=False,
auth_type="basic",
username="admin",
priority=50,
@@ -116,7 +114,6 @@ class TestUpstreamSourceModel:
assert source.source_type == "npm"
assert source.url == "https://npm.example.com"
assert source.enabled is True
assert source.is_public is False
assert source.auth_type == "basic"
assert source.username == "admin"
assert source.priority == 50
@@ -260,7 +257,6 @@ class TestUpstreamSourceSchemas:
source_type="npm",
url="https://npm.example.com",
enabled=True,
is_public=False,
auth_type="basic",
username="admin",
password="secret",
@@ -281,7 +277,6 @@ class TestUpstreamSourceSchemas:
)
assert source.source_type == "generic"
assert source.enabled is False
assert source.is_public is True
assert source.auth_type == "none"
assert source.priority == 100
@@ -578,7 +573,6 @@ class TestUpstreamClientSourceMatching:
name="npm-public",
url="https://registry.npmjs.org",
enabled=True,
is_public=True,
auth_type="none",
priority=100,
)
@@ -603,7 +597,6 @@ class TestUpstreamClientSourceMatching:
name="npm-private",
url="https://registry.npmjs.org",
enabled=True,
is_public=False,
auth_type="basic",
priority=50,
)
@@ -611,7 +604,6 @@ class TestUpstreamClientSourceMatching:
name="npm-public",
url="https://registry.npmjs.org",
enabled=True,
is_public=True,
auth_type="none",
priority=100,
)
@@ -711,89 +703,6 @@ class TestUpstreamClientAuthHeaders:
assert auth is None
class TestUpstreamClientAirGapMode:
"""Tests for air-gap mode enforcement."""
def test_airgap_blocks_public_source(self):
"""Test that air-gap mode blocks public sources."""
from app.models import UpstreamSource, CacheSettings
from app.upstream import UpstreamClient, AirGapError
source = UpstreamSource(
name="npm-public",
url="https://registry.npmjs.org",
enabled=True,
is_public=True,
auth_type="none",
priority=100,
)
settings = CacheSettings(allow_public_internet=False)
client = UpstreamClient(sources=[source], cache_settings=settings)
with pytest.raises(AirGapError) as exc_info:
client.fetch("https://registry.npmjs.org/lodash")
assert "Air-gap mode enabled" in str(exc_info.value)
assert "public source" in str(exc_info.value)
def test_airgap_blocks_unmatched_url(self):
"""Test that air-gap mode blocks URLs not matching any source."""
from app.models import CacheSettings
from app.upstream import UpstreamClient, AirGapError
settings = CacheSettings(allow_public_internet=False)
client = UpstreamClient(sources=[], cache_settings=settings)
with pytest.raises(AirGapError) as exc_info:
client.fetch("https://example.com/file.tgz")
assert "Air-gap mode enabled" in str(exc_info.value)
assert "does not match any configured" in str(exc_info.value)
def test_airgap_allows_private_source(self):
"""Test that air-gap mode allows private sources."""
from app.models import UpstreamSource, CacheSettings
from app.upstream import UpstreamClient, SourceDisabledError
source = UpstreamSource(
name="npm-private",
url="https://npm.internal.corp",
enabled=False, # Disabled, but would pass air-gap check
is_public=False,
auth_type="none",
priority=100,
)
settings = CacheSettings(allow_public_internet=False)
client = UpstreamClient(sources=[source], cache_settings=settings)
# Should fail due to disabled source, not air-gap
with pytest.raises(SourceDisabledError):
client.fetch("https://npm.internal.corp/package.tgz")
def test_allow_public_internet_true(self):
"""Test that public internet is allowed when setting is true."""
from app.models import UpstreamSource, CacheSettings
from app.upstream import UpstreamClient, SourceDisabledError
source = UpstreamSource(
name="npm-public",
url="https://registry.npmjs.org",
enabled=False, # Disabled
is_public=True,
auth_type="none",
priority=100,
)
settings = CacheSettings(allow_public_internet=True)
client = UpstreamClient(sources=[source], cache_settings=settings)
# Should fail due to disabled source, not air-gap
with pytest.raises(SourceDisabledError):
client.fetch("https://registry.npmjs.org/lodash")
class TestUpstreamClientSourceDisabled:
"""Tests for disabled source handling."""
@@ -806,7 +715,6 @@ class TestUpstreamClientSourceDisabled:
name="npm-public",
url="https://registry.npmjs.org",
enabled=False,
is_public=True,
auth_type="none",
priority=100,
)
@@ -979,13 +887,6 @@ class TestUpstreamExceptions:
assert error.status_code == 404
assert error.response_headers == {"x-custom": "value"}
def test_airgap_error(self):
"""Test AirGapError."""
from app.upstream import AirGapError
error = AirGapError("Blocked by air-gap")
assert "Blocked by air-gap" in str(error)
def test_source_not_found_error(self):
"""Test SourceNotFoundError."""
from app.upstream import SourceNotFoundError
@@ -1420,7 +1321,6 @@ class TestUpstreamSourcesAdminAPI:
"source_type": "generic",
"url": "https://example.com/packages",
"enabled": False,
"is_public": False,
"auth_type": "none",
"priority": 200,
},
@@ -1432,7 +1332,6 @@ class TestUpstreamSourcesAdminAPI:
assert data["source_type"] == "generic"
assert data["url"] == "https://example.com/packages"
assert data["enabled"] is False
assert data["is_public"] is False
assert data["priority"] == 200
assert "id" in data
@@ -1452,7 +1351,6 @@ class TestUpstreamSourcesAdminAPI:
"source_type": "npm",
"url": "https://npm.internal.corp",
"enabled": False,
"is_public": False,
"auth_type": "basic",
"username": "reader",
"password": "secret123",
@@ -1958,7 +1856,6 @@ class TestEnvVarUpstreamSourcesParsing:
# Check defaults
assert test_source.source_type == "generic"
assert test_source.enabled is True
assert test_source.is_public is True
assert test_source.auth_type == "none"
assert test_source.priority == 100
finally:
@@ -1981,7 +1878,6 @@ class TestEnvSourceToResponse:
url="https://example.com",
source_type="npm",
enabled=True,
is_public=False,
auth_type="basic",
username="user",
password="pass",
@@ -1992,7 +1888,6 @@ class TestEnvSourceToResponse:
assert source.url == "https://example.com"
assert source.source_type == "npm"
assert source.enabled is True
assert source.is_public is False
assert source.auth_type == "basic"
assert source.username == "user"
assert source.password == "pass"

View File

@@ -46,8 +46,6 @@ import {
UpstreamSourceCreate,
UpstreamSourceUpdate,
UpstreamSourceTestResult,
CacheSettings,
CacheSettingsUpdate,
} from './types';
const API_BASE = '/api/v1';
@@ -748,21 +746,3 @@ export async function testUpstreamSource(id: string): Promise<UpstreamSourceTest
});
return handleResponse<UpstreamSourceTestResult>(response);
}
// Cache Settings Admin API
export async function getCacheSettings(): Promise<CacheSettings> {
const response = await fetch(`${API_BASE}/admin/cache-settings`, {
credentials: 'include',
});
return handleResponse<CacheSettings>(response);
}
export async function updateCacheSettings(data: CacheSettingsUpdate): Promise<CacheSettings> {
const response = await fetch(`${API_BASE}/admin/cache-settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
credentials: 'include',
});
return handleResponse<CacheSettings>(response);
}

View File

@@ -272,7 +272,7 @@
.footer {
background: var(--bg-secondary);
border-top: 1px solid var(--border-primary);
padding: 24px 0;
padding: 12px 0;
}
.footer-content {

View File

@@ -34,74 +34,6 @@
margin-bottom: 1rem;
}
/* Settings Section */
.settings-section {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.settings-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.toggle-label {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.setting-name {
font-weight: 500;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 0.5rem;
}
.setting-description {
font-size: 0.85rem;
color: var(--text-secondary);
}
.toggle-button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
min-width: 100px;
}
.toggle-button.on {
background-color: #28a745;
color: white;
}
.toggle-button.off {
background-color: #dc3545;
color: white;
}
.toggle-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Sources Section */
.sources-section {
background: var(--bg-secondary);
@@ -133,7 +65,7 @@
.sources-table th,
.sources-table td {
padding: 0.75rem 1rem;
text-align: left;
text-align: center;
border-bottom: 1px solid var(--border-color);
}
@@ -156,6 +88,12 @@
.source-name {
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
}
/* Name column should be left-aligned */
.sources-table td:first-child {
text-align: left;
}
.url-cell {
@@ -165,10 +103,10 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
/* Badges */
.public-badge,
.env-badge,
.status-badge {
display: inline-block;
@@ -179,11 +117,6 @@
margin-left: 0.5rem;
}
.public-badge {
background-color: #e3f2fd;
color: #1976d2;
}
.env-badge {
background-color: #fff3e0;
color: #e65100;
@@ -212,18 +145,67 @@
margin-right: 0;
}
.test-result {
display: inline-block;
margin-left: 0.5rem;
font-size: 0.85rem;
.test-cell {
text-align: center;
width: 2rem;
}
.test-result.success {
.test-dot {
font-size: 1rem;
cursor: default;
}
.test-dot.success {
color: #2e7d32;
}
.test-result.failure {
.test-dot.failure {
color: #c62828;
cursor: pointer;
}
.test-dot.failure:hover {
color: #b71c1c;
}
.test-dot.testing {
color: #1976d2;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Error Modal */
.error-modal-content {
background: var(--bg-primary);
border-radius: 8px;
padding: 2rem;
width: 100%;
max-width: 500px;
}
.error-modal-content h3 {
margin-top: 0;
color: #c62828;
}
.error-modal-content .error-details {
background: var(--bg-tertiary);
padding: 1rem;
border-radius: 4px;
font-family: monospace;
font-size: 0.9rem;
word-break: break-word;
white-space: pre-wrap;
}
.error-modal-content .modal-actions {
display: flex;
justify-content: flex-end;
margin-top: 1.5rem;
}
/* Buttons */
@@ -267,10 +249,22 @@
}
.btn-sm {
padding: 0.25rem 0.5rem;
padding: 0.25rem 0.75rem;
font-size: 0.8rem;
}
.btn-secondary {
background-color: var(--bg-tertiary);
border-color: var(--border-color);
color: var(--text-primary);
font-weight: 500;
}
.btn-secondary:hover {
background-color: var(--bg-secondary);
border-color: var(--text-secondary);
}
.empty-message {
color: var(--text-secondary);
font-style: italic;
@@ -364,9 +358,14 @@
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
justify-content: space-between;
align-items: center;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.form-actions-right {
display: flex;
gap: 0.5rem;
}

View File

@@ -7,10 +7,8 @@ import {
updateUpstreamSource,
deleteUpstreamSource,
testUpstreamSource,
getCacheSettings,
updateCacheSettings,
} from '../api';
import { UpstreamSource, CacheSettings, SourceType, AuthType } from '../types';
import { UpstreamSource, SourceType, AuthType } from '../types';
import './AdminCachePage.css';
const SOURCE_TYPES: SourceType[] = ['npm', 'pypi', 'maven', 'docker', 'helm', 'nuget', 'deb', 'rpm', 'generic'];
@@ -25,11 +23,6 @@ function AdminCachePage() {
const [loadingSources, setLoadingSources] = useState(true);
const [sourcesError, setSourcesError] = useState<string | null>(null);
// Cache settings state
const [settings, setSettings] = useState<CacheSettings | null>(null);
const [loadingSettings, setLoadingSettings] = useState(true);
const [settingsError, setSettingsError] = useState<string | null>(null);
// Create/Edit form state
const [showForm, setShowForm] = useState(false);
const [editingSource, setEditingSource] = useState<UpstreamSource | null>(null);
@@ -38,7 +31,6 @@ function AdminCachePage() {
source_type: 'generic' as SourceType,
url: '',
enabled: true,
is_public: true,
auth_type: 'none' as AuthType,
username: '',
password: '',
@@ -54,12 +46,13 @@ function AdminCachePage() {
// Delete confirmation state
const [deletingId, setDeletingId] = useState<string | null>(null);
// Settings update state
const [updatingSettings, setUpdatingSettings] = useState(false);
// Success message
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Error modal state
const [showErrorModal, setShowErrorModal] = useState(false);
const [selectedError, setSelectedError] = useState<{ sourceName: string; error: string } | null>(null);
useEffect(() => {
if (!authLoading && !user) {
navigate('/login', { state: { from: '/admin/cache' } });
@@ -69,7 +62,6 @@ function AdminCachePage() {
useEffect(() => {
if (user && user.is_admin) {
loadSources();
loadSettings();
}
}, [user]);
@@ -93,19 +85,6 @@ function AdminCachePage() {
}
}
async function loadSettings() {
setLoadingSettings(true);
setSettingsError(null);
try {
const data = await getCacheSettings();
setSettings(data);
} catch (err) {
setSettingsError(err instanceof Error ? err.message : 'Failed to load settings');
} finally {
setLoadingSettings(false);
}
}
function openCreateForm() {
setEditingSource(null);
setFormData({
@@ -113,7 +92,6 @@ function AdminCachePage() {
source_type: 'generic',
url: '',
enabled: true,
is_public: true,
auth_type: 'none',
username: '',
password: '',
@@ -130,7 +108,6 @@ function AdminCachePage() {
source_type: source.source_type,
url: source.url,
enabled: source.enabled,
is_public: source.is_public,
auth_type: source.auth_type,
username: source.username || '',
password: '',
@@ -155,6 +132,8 @@ function AdminCachePage() {
setFormError(null);
try {
let savedSourceId: string | null = null;
if (editingSource) {
// Update existing source
await updateUpstreamSource(editingSource.id, {
@@ -162,30 +141,35 @@ function AdminCachePage() {
source_type: formData.source_type,
url: formData.url.trim(),
enabled: formData.enabled,
is_public: formData.is_public,
auth_type: formData.auth_type,
username: formData.username.trim() || undefined,
password: formData.password || undefined,
priority: formData.priority,
});
savedSourceId = editingSource.id;
setSuccessMessage('Source updated successfully');
} else {
// Create new source
await createUpstreamSource({
const newSource = await createUpstreamSource({
name: formData.name.trim(),
source_type: formData.source_type,
url: formData.url.trim(),
enabled: formData.enabled,
is_public: formData.is_public,
auth_type: formData.auth_type,
username: formData.username.trim() || undefined,
password: formData.password || undefined,
priority: formData.priority,
});
savedSourceId = newSource.id;
setSuccessMessage('Source created successfully');
}
setShowForm(false);
await loadSources();
// Auto-test the source after save
if (savedSourceId) {
testSourceById(savedSourceId);
}
} catch (err) {
setFormError(err instanceof Error ? err.message : 'Failed to save source');
} finally {
@@ -211,24 +195,28 @@ function AdminCachePage() {
}
async function handleTest(source: UpstreamSource) {
setTestingId(source.id);
setTestResults((prev) => ({ ...prev, [source.id]: { success: true, message: 'Testing...' } }));
testSourceById(source.id);
}
async function testSourceById(sourceId: string) {
setTestingId(sourceId);
setTestResults((prev) => ({ ...prev, [sourceId]: { success: true, message: 'Testing...' } }));
try {
const result = await testUpstreamSource(source.id);
const result = await testUpstreamSource(sourceId);
setTestResults((prev) => ({
...prev,
[source.id]: {
[sourceId]: {
success: result.success,
message: result.success
? `Connected (${result.elapsed_ms}ms)`
? `OK (${result.elapsed_ms}ms)`
: result.error || `HTTP ${result.status_code}`,
},
}));
} catch (err) {
setTestResults((prev) => ({
...prev,
[source.id]: {
[sourceId]: {
success: false,
message: err instanceof Error ? err.message : 'Test failed',
},
@@ -238,30 +226,9 @@ function AdminCachePage() {
}
}
async function handleSettingsToggle(field: 'allow_public_internet' | 'auto_create_system_projects') {
if (!settings) return;
// Check if env override is active
const isOverridden =
(field === 'allow_public_internet' && settings.allow_public_internet_env_override !== null) ||
(field === 'auto_create_system_projects' && settings.auto_create_system_projects_env_override !== null);
if (isOverridden) {
alert('This setting is overridden by an environment variable and cannot be changed via UI.');
return;
}
setUpdatingSettings(true);
try {
const update = { [field]: !settings[field] };
const newSettings = await updateCacheSettings(update);
setSettings(newSettings);
setSuccessMessage(`Setting "${field}" updated`);
} catch (err) {
setSettingsError(err instanceof Error ? err.message : 'Failed to update settings');
} finally {
setUpdatingSettings(false);
}
function showError(sourceName: string, error: string) {
setSelectedError({ sourceName, error });
setShowErrorModal(true);
}
if (authLoading) {
@@ -278,71 +245,13 @@ function AdminCachePage() {
return (
<div className="admin-cache-page">
<h1>Cache Management</h1>
<h1>Upstream Sources</h1>
{successMessage && <div className="success-message">{successMessage}</div>}
{/* Cache Settings Section */}
<section className="settings-section">
<h2>Global Settings</h2>
{loadingSettings ? (
<p>Loading settings...</p>
) : settingsError ? (
<div className="error-message">{settingsError}</div>
) : settings ? (
<div className="settings-grid">
<div className="setting-item">
<label className="toggle-label">
<span className="setting-name">
Allow Public Internet
{settings.allow_public_internet_env_override !== null && (
<span className="env-badge" title="Overridden by environment variable">
ENV
</span>
)}
</span>
<span className="setting-description">
When disabled (air-gap mode), requests to public sources are blocked.
</span>
</label>
<button
className={`toggle-button ${settings.allow_public_internet ? 'on' : 'off'}`}
onClick={() => handleSettingsToggle('allow_public_internet')}
disabled={updatingSettings || settings.allow_public_internet_env_override !== null}
>
{settings.allow_public_internet ? 'Enabled' : 'Disabled'}
</button>
</div>
<div className="setting-item">
<label className="toggle-label">
<span className="setting-name">
Auto-create System Projects
{settings.auto_create_system_projects_env_override !== null && (
<span className="env-badge" title="Overridden by environment variable">
ENV
</span>
)}
</span>
<span className="setting-description">
Automatically create system projects (_npm, _pypi, etc.) on first cache request.
</span>
</label>
<button
className={`toggle-button ${settings.auto_create_system_projects ? 'on' : 'off'}`}
onClick={() => handleSettingsToggle('auto_create_system_projects')}
disabled={updatingSettings || settings.auto_create_system_projects_env_override !== null}
>
{settings.auto_create_system_projects ? 'Enabled' : 'Disabled'}
</button>
</div>
</div>
) : null}
</section>
{/* Upstream Sources Section */}
<section className="sources-section">
<div className="section-header">
<h2>Upstream Sources</h2>
<button className="btn btn-primary" onClick={openCreateForm}>
Add Source
</button>
@@ -363,7 +272,7 @@ function AdminCachePage() {
<th>URL</th>
<th>Priority</th>
<th>Status</th>
<th>Source</th>
<th>Test</th>
<th>Actions</th>
</tr>
</thead>
@@ -372,51 +281,45 @@ function AdminCachePage() {
<tr key={source.id} className={source.enabled ? '' : 'disabled-row'}>
<td>
<span className="source-name">{source.name}</span>
{source.is_public && <span className="public-badge">Public</span>}
{source.source === 'env' && (
<span className="env-badge" title="Defined via environment variable">ENV</span>
)}
</td>
<td>{source.source_type}</td>
<td className="url-cell">{source.url}</td>
<td className="url-cell" title={source.url}>{source.url}</td>
<td>{source.priority}</td>
<td>
<span className={`status-badge ${source.enabled ? 'enabled' : 'disabled'}`}>
{source.enabled ? 'Enabled' : 'Disabled'}
</span>
</td>
<td>
{source.source === 'env' ? (
<span className="env-badge" title="Defined via environment variable">
ENV
</span>
<td className="test-cell">
{testingId === source.id ? (
<span className="test-dot testing" title="Testing..."></span>
) : testResults[source.id] ? (
testResults[source.id].success ? (
<span className="test-dot success" title={testResults[source.id].message}></span>
) : (
'Database'
)}
<span
className="test-dot failure"
title="Click to see error"
onClick={() => showError(source.name, testResults[source.id].message)}
></span>
)
) : null}
</td>
<td className="actions-cell">
<button
className="btn btn-sm"
className="btn btn-sm btn-secondary"
onClick={() => handleTest(source)}
disabled={testingId === source.id}
>
{testingId === source.id ? 'Testing...' : 'Test'}
Test
</button>
{source.source !== 'env' && (
<>
<button className="btn btn-sm" onClick={() => openEditForm(source)}>
<button className="btn btn-sm btn-secondary" onClick={() => openEditForm(source)}>
Edit
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => handleDelete(source)}
disabled={deletingId === source.id}
>
{deletingId === source.id ? 'Deleting...' : 'Delete'}
</button>
</>
)}
{testResults[source.id] && (
<span className={`test-result ${testResults[source.id].success ? 'success' : 'failure'}`}>
{testResults[source.id].message}
</span>
)}
</td>
</tr>
@@ -498,16 +401,6 @@ function AdminCachePage() {
Enabled
</label>
</div>
<div className="form-group checkbox-group">
<label>
<input
type="checkbox"
checked={formData.is_public}
onChange={(e) => setFormData({ ...formData, is_public: e.target.checked })}
/>
Public Internet Source
</label>
</div>
</div>
<div className="form-group">
@@ -562,6 +455,20 @@ function AdminCachePage() {
)}
<div className="form-actions">
{editingSource && (
<button
type="button"
className="btn btn-danger"
onClick={() => {
handleDelete(editingSource);
setShowForm(false);
}}
disabled={deletingId === editingSource.id}
>
{deletingId === editingSource.id ? 'Deleting...' : 'Delete'}
</button>
)}
<div className="form-actions-right">
<button type="button" className="btn" onClick={() => setShowForm(false)}>
Cancel
</button>
@@ -569,10 +476,26 @@ function AdminCachePage() {
{isSaving ? 'Saving...' : editingSource ? 'Update' : 'Create'}
</button>
</div>
</div>
</form>
</div>
</div>
)}
{/* Error Details Modal */}
{showErrorModal && selectedError && (
<div className="modal-overlay" onClick={() => setShowErrorModal(false)}>
<div className="error-modal-content" onClick={(e) => e.stopPropagation()}>
<h3>Connection Error: {selectedError.sourceName}</h3>
<div className="error-details">{selectedError.error}</div>
<div className="modal-actions">
<button className="btn" onClick={() => setShowErrorModal(false)}>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -515,7 +515,6 @@ export interface UpstreamSource {
source_type: SourceType;
url: string;
enabled: boolean;
is_public: boolean;
auth_type: AuthType;
username: string | null;
has_password: boolean;
@@ -531,7 +530,6 @@ export interface UpstreamSourceCreate {
source_type: SourceType;
url: string;
enabled?: boolean;
is_public?: boolean;
auth_type?: AuthType;
username?: string;
password?: string;
@@ -544,7 +542,6 @@ export interface UpstreamSourceUpdate {
source_type?: SourceType;
url?: string;
enabled?: boolean;
is_public?: boolean;
auth_type?: AuthType;
username?: string;
password?: string;
@@ -560,18 +557,3 @@ export interface UpstreamSourceTestResult {
source_id: string;
source_name: string;
}
// Cache Settings types
export interface CacheSettings {
allow_public_internet: boolean;
auto_create_system_projects: boolean;
allow_public_internet_env_override: boolean | null;
auto_create_system_projects_env_override: boolean | null;
created_at: string | null;
updated_at: string | null;
}
export interface CacheSettingsUpdate {
allow_public_internet?: boolean;
auto_create_system_projects?: boolean;
}

View File

@@ -128,6 +128,10 @@ spec:
value: {{ .Values.orchard.rateLimit.login | quote }}
{{- end }}
{{- end }}
{{- if .Values.orchard.purgeSeedData }}
- name: ORCHARD_PURGE_SEED_DATA
value: "true"
{{- end }}
{{- if .Values.orchard.database.poolSize }}
- name: ORCHARD_DATABASE_POOL_SIZE
value: {{ .Values.orchard.database.poolSize | quote }}

View File

@@ -91,6 +91,7 @@ affinity: {}
# Orchard server configuration
orchard:
env: "development" # Allows seed data for testing
purgeSeedData: true # Remove public seed data (npm-public, pypi-public, etc.)
server:
host: "0.0.0.0"
port: 8080