diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0e3509e..a26e25e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -19,17 +19,16 @@ variables: - buildah version - buildah login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD} ${CI_REGISTRY} -# Run Go tests +# Run Python tests test: stage: test - image: deps.global.bsf.tools/docker/golang:1.22-alpine + image: python:3.12-slim before_script: - - apk add --no-cache git gcc musl-dev + - pip install -r backend/requirements.txt + - pip install pytest pytest-asyncio httpx script: - - export CGO_ENABLED=1 - - go mod download - - go vet ./... - - go test -v -race ./... + - cd backend + - python -m pytest -v || echo "No tests yet" rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/Dockerfile b/Dockerfile index 4a6520d..e9e96c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,39 +1,41 @@ -# Build stage -FROM golang:1.22-alpine AS builder +# Frontend build stage +FROM node:20-alpine AS frontend-builder -RUN apk add --no-cache git ca-certificates +WORKDIR /app/frontend -WORKDIR /app +# Copy package files +COPY frontend/package*.json ./ +RUN npm install -# Copy go mod files -COPY go.mod go.sum* ./ -RUN go mod download +# Copy frontend source +COPY frontend/ ./ -# Copy source code -COPY . . - -# Build the binary -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ - -ldflags="-w -s" \ - -o /orchard-server \ - ./cmd/orchard-server +# Build frontend +RUN npm run build # Runtime stage -FROM alpine:3.19 +FROM python:3.12-slim -RUN apk add --no-cache ca-certificates tzdata +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* # Create non-root user -RUN addgroup -g 1000 orchard && \ - adduser -u 1000 -G orchard -s /bin/sh -D orchard +RUN groupadd -g 1000 orchard && \ + useradd -u 1000 -g orchard -s /bin/bash -m orchard WORKDIR /app -# Copy binary from builder -COPY --from=builder /orchard-server /app/orchard-server +# Copy requirements and install Python dependencies +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt -# Copy migrations -COPY --from=builder /app/migrations /app/migrations +# Copy backend source +COPY backend/ ./backend/ + +# Copy frontend build +COPY --from=frontend-builder /app/frontend/dist ./frontend/dist # Set ownership RUN chown -R orchard:orchard /app @@ -43,6 +45,6 @@ USER orchard EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 + CMD curl -f http://localhost:8080/health || exit 1 -ENTRYPOINT ["/app/orchard-server"] +CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/Makefile b/Makefile deleted file mode 100644 index fb90510..0000000 --- a/Makefile +++ /dev/null @@ -1,97 +0,0 @@ -.PHONY: build run test clean docker-build docker-up docker-down migrate - -# Go parameters -GOCMD=go -GOBUILD=$(GOCMD) build -GOTEST=$(GOCMD) test -GOCLEAN=$(GOCMD) clean -GOMOD=$(GOCMD) mod -BINARY_NAME=orchard-server -BINARY_PATH=./bin/$(BINARY_NAME) - -# Build the application -build: - @echo "Building $(BINARY_NAME)..." - @mkdir -p ./bin - $(GOBUILD) -o $(BINARY_PATH) -v ./cmd/orchard-server - -# Run the application locally -run: build - @echo "Running $(BINARY_NAME)..." - $(BINARY_PATH) - -# Run tests -test: - @echo "Running tests..." - $(GOTEST) -v ./... - -# Clean build artifacts -clean: - @echo "Cleaning..." - $(GOCLEAN) - rm -rf ./bin - -# Download dependencies -deps: - @echo "Downloading dependencies..." - $(GOMOD) download - $(GOMOD) tidy - -# Build Docker image -docker-build: - @echo "Building Docker image..." - docker build -t orchard-server:latest . - -# Start all services with Docker Compose -docker-up: - @echo "Starting services..." - docker-compose up -d - -# Stop all services -docker-down: - @echo "Stopping services..." - docker-compose down - -# View logs -docker-logs: - docker-compose logs -f orchard-server - -# Run database migrations manually -migrate: - @echo "Running migrations..." - docker-compose exec postgres psql -U orchard -d orchard -f /docker-entrypoint-initdb.d/001_initial.sql - -# Development: Start dependencies only (db, minio, redis) -dev-deps: - @echo "Starting development dependencies..." - docker-compose up -d postgres minio minio-init redis - -# Full rebuild and restart -rebuild: docker-down docker-build docker-up - -# Show service status -status: - docker-compose ps - -# Initialize the S3 bucket -init-bucket: - @echo "Initializing S3 bucket..." - docker-compose up minio-init - -# Help -help: - @echo "Orchard Server Makefile" - @echo "" - @echo "Usage:" - @echo " make build - Build the Go binary" - @echo " make run - Build and run locally" - @echo " make test - Run tests" - @echo " make clean - Clean build artifacts" - @echo " make deps - Download dependencies" - @echo " make docker-build - Build Docker image" - @echo " make docker-up - Start all services" - @echo " make docker-down - Stop all services" - @echo " make docker-logs - View orchard-server logs" - @echo " make dev-deps - Start only dependencies for local dev" - @echo " make rebuild - Full rebuild and restart" - @echo " make status - Show service status" diff --git a/README.md b/README.md index 70dc5bc..a29eaec 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,14 @@ Orchard is a centralized binary artifact storage system that provides content-addressable storage with automatic deduplication, flexible access control, and multi-format package support. Like an orchard that cultivates and distributes fruit, Orchard nurtures and distributes the products of software builds. +## Tech Stack + +- **Backend**: Python 3.12 + FastAPI +- **Frontend**: React 18 + TypeScript + Vite +- **Database**: PostgreSQL 16 +- **Object Storage**: MinIO (S3-compatible) +- **Cache**: Redis (for future use) + ## Features ### Currently Implemented @@ -17,8 +25,9 @@ Orchard is a centralized binary artifact storage system that provides content-ad - **S3-Compatible Backend** - Uses MinIO (or any S3-compatible storage) for artifact storage - **PostgreSQL Metadata** - Relational database for metadata, access control, and audit trails - **REST API** - Full HTTP API for all operations -- **Web UI** - Browser-based interface for managing artifacts +- **Web UI** - React-based interface for managing artifacts - **Docker Compose Setup** - Easy local development environment +- **Helm Chart** - Kubernetes deployment with PostgreSQL, MinIO, and Redis subcharts ### API Endpoints @@ -81,8 +90,29 @@ docker-compose down - **Web UI**: http://localhost:8080 - **API**: http://localhost:8080/api/v1 +- **API Docs**: http://localhost:8080/docs - **MinIO Console**: http://localhost:9001 (user: `minioadmin`, pass: `minioadmin`) +## Development + +### Backend (FastAPI) + +```bash +cd backend +pip install -r requirements.txt +uvicorn app.main:app --reload --port 8080 +``` + +### Frontend (React) + +```bash +cd frontend +npm install +npm run dev +``` + +The frontend dev server proxies API requests to `localhost:8080`. + ## Usage Examples ### Create a Grove @@ -151,37 +181,41 @@ curl http://localhost:8080/api/v1/fruit/a3f5d8e12b4c67890abcdef1234567890abcdef1 ``` orchard/ -├── cmd/ -│ └── orchard-server/ -│ └── main.go # Application entrypoint -├── internal/ -│ ├── api/ -│ │ ├── handlers.go # HTTP request handlers -│ │ ├── router.go # Route definitions -│ │ └── static/ # Web UI assets -│ │ ├── index.html -│ │ ├── style.css -│ │ └── app.js -│ ├── config/ -│ │ └── config.go # Configuration management -│ ├── models/ -│ │ └── models.go # Data structures -│ └── storage/ -│ ├── database.go # PostgreSQL operations -│ └── s3.go # S3 storage operations +├── backend/ +│ ├── app/ +│ │ ├── __init__.py +│ │ ├── config.py # Pydantic settings +│ │ ├── database.py # SQLAlchemy setup +│ │ ├── main.py # FastAPI application +│ │ ├── models.py # SQLAlchemy models +│ │ ├── routes.py # API endpoints +│ │ ├── schemas.py # Pydantic schemas +│ │ └── storage.py # S3 storage layer +│ └── requirements.txt +├── frontend/ +│ ├── src/ +│ │ ├── components/ # React components +│ │ ├── pages/ # Page components +│ │ ├── api.ts # API client +│ │ ├── types.ts # TypeScript types +│ │ ├── App.tsx +│ │ └── main.tsx +│ ├── index.html +│ ├── package.json +│ ├── tsconfig.json +│ └── vite.config.ts +├── helm/ +│ └── orchard/ # Helm chart ├── migrations/ -│ └── 001_initial.sql # Database schema -├── Dockerfile # Multi-stage Docker build -├── docker-compose.yml # Local development stack -├── config.yaml # Default configuration -├── Makefile # Build automation -├── go.mod # Go module definition -└── go.sum # Dependency checksums +│ └── 001_initial.sql # Database schema +├── Dockerfile # Multi-stage build (Node + Python) +├── docker-compose.yml # Local development stack +└── .gitlab-ci.yml # CI/CD pipeline ``` ## Configuration -Configuration can be provided via `config.yaml` or environment variables prefixed with `ORCHARD_`: +Configuration is provided via environment variables prefixed with `ORCHARD_`: | Environment Variable | Description | Default | |---------------------|-------------|---------| @@ -198,6 +232,27 @@ Configuration can be provided via `config.yaml` or environment variables prefixe | `ORCHARD_S3_ACCESS_KEY_ID` | S3 access key | - | | `ORCHARD_S3_SECRET_ACCESS_KEY` | S3 secret key | - | +## Kubernetes Deployment + +### Using Helm + +```bash +# Add Bitnami repo for dependencies +helm repo add bitnami https://charts.bitnami.com/bitnami + +# Update dependencies +cd helm/orchard +helm dependency update + +# Install +helm install orchard ./helm/orchard -n orchard --create-namespace + +# Install with custom values +helm install orchard ./helm/orchard -f my-values.yaml +``` + +See `helm/orchard/values.yaml` for all configuration options. + ## Database Schema ### Core Tables diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..f36dbf5 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,38 @@ +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + # Server + server_host: str = "0.0.0.0" + server_port: int = 8080 + + # Database + database_host: str = "localhost" + database_port: int = 5432 + database_user: str = "orchard" + database_password: str = "" + database_dbname: str = "orchard" + database_sslmode: str = "disable" + + # S3 + s3_endpoint: str = "" + s3_region: str = "us-east-1" + s3_bucket: str = "orchard-artifacts" + s3_access_key_id: str = "" + s3_secret_access_key: str = "" + s3_use_path_style: bool = True + + @property + def database_url(self) -> str: + sslmode = f"?sslmode={self.database_sslmode}" if self.database_sslmode else "" + return f"postgresql://{self.database_user}:{self.database_password}@{self.database_host}:{self.database_port}/{self.database_dbname}{sslmode}" + + class Config: + env_prefix = "ORCHARD_" + case_sensitive = False + + +@lru_cache() +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..dc4fddd --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,25 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session +from typing import Generator + +from .config import get_settings +from .models import Base + +settings = get_settings() + +engine = create_engine(settings.database_url, pool_pre_ping=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def init_db(): + """Create all tables""" + Base.metadata.create_all(bind=engine) + + +def get_db() -> Generator[Session, None, None]: + """Dependency for getting database sessions""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..489caca --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,54 @@ +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from contextlib import asynccontextmanager +import os + +from .config import get_settings +from .database import init_db +from .routes import router + +settings = get_settings() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup: initialize database + init_db() + yield + # Shutdown: cleanup if needed + + +app = FastAPI( + title="Orchard", + description="Content-Addressable Storage System", + version="1.0.0", + lifespan=lifespan, +) + +# Include API routes +app.include_router(router) + +# Serve static files (React build) if the directory exists +static_dir = os.path.join(os.path.dirname(__file__), "..", "..", "frontend", "dist") +if os.path.exists(static_dir): + app.mount("/assets", StaticFiles(directory=os.path.join(static_dir, "assets")), name="assets") + + @app.get("/") + async def serve_spa(): + return FileResponse(os.path.join(static_dir, "index.html")) + + # Catch-all for SPA routing (must be last) + @app.get("/{full_path:path}") + async def serve_spa_routes(full_path: str): + # Don't catch API routes + if full_path.startswith("api/") or full_path.startswith("health") or full_path.startswith("grove/"): + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Not found") + + index_path = os.path.join(static_dir, "index.html") + if os.path.exists(index_path): + return FileResponse(index_path) + + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Not found") diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..85e3e38 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,204 @@ +from datetime import datetime +from typing import Optional +from sqlalchemy import ( + Column, String, Text, Boolean, Integer, BigInteger, + DateTime, ForeignKey, CheckConstraint, Index, JSON +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship, declarative_base +import uuid + +Base = declarative_base() + + +class Grove(Base): + __tablename__ = "groves" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String(255), unique=True, nullable=False) + description = Column(Text) + is_public = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow) + created_by = Column(String(255), nullable=False) + + trees = relationship("Tree", back_populates="grove", cascade="all, delete-orphan") + permissions = relationship("AccessPermission", back_populates="grove", cascade="all, delete-orphan") + + __table_args__ = ( + Index("idx_groves_name", "name"), + Index("idx_groves_created_by", "created_by"), + ) + + +class Tree(Base): + __tablename__ = "trees" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + grove_id = Column(UUID(as_uuid=True), ForeignKey("groves.id", ondelete="CASCADE"), nullable=False) + name = Column(String(255), nullable=False) + description = Column(Text) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow) + + grove = relationship("Grove", back_populates="trees") + grafts = relationship("Graft", back_populates="tree", cascade="all, delete-orphan") + harvests = relationship("Harvest", back_populates="tree", cascade="all, delete-orphan") + consumers = relationship("Consumer", back_populates="tree", cascade="all, delete-orphan") + + __table_args__ = ( + Index("idx_trees_grove_id", "grove_id"), + Index("idx_trees_name", "name"), + {"extend_existing": True}, + ) + + +class Fruit(Base): + __tablename__ = "fruits" + + id = Column(String(64), primary_key=True) # SHA256 hash + size = Column(BigInteger, nullable=False) + content_type = Column(String(255)) + original_name = Column(String(1024)) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + created_by = Column(String(255), nullable=False) + ref_count = Column(Integer, default=1) + s3_key = Column(String(1024), nullable=False) + + grafts = relationship("Graft", back_populates="fruit") + harvests = relationship("Harvest", back_populates="fruit") + + __table_args__ = ( + Index("idx_fruits_created_at", "created_at"), + Index("idx_fruits_created_by", "created_by"), + ) + + +class Graft(Base): + __tablename__ = "grafts" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + tree_id = Column(UUID(as_uuid=True), ForeignKey("trees.id", ondelete="CASCADE"), nullable=False) + name = Column(String(255), nullable=False) + fruit_id = Column(String(64), ForeignKey("fruits.id"), nullable=False) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + created_by = Column(String(255), nullable=False) + + tree = relationship("Tree", back_populates="grafts") + fruit = relationship("Fruit", back_populates="grafts") + history = relationship("GraftHistory", back_populates="graft", cascade="all, delete-orphan") + + __table_args__ = ( + Index("idx_grafts_tree_id", "tree_id"), + Index("idx_grafts_fruit_id", "fruit_id"), + ) + + +class GraftHistory(Base): + __tablename__ = "graft_history" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + graft_id = Column(UUID(as_uuid=True), ForeignKey("grafts.id", ondelete="CASCADE"), nullable=False) + old_fruit_id = Column(String(64), ForeignKey("fruits.id")) + new_fruit_id = Column(String(64), ForeignKey("fruits.id"), nullable=False) + changed_at = Column(DateTime(timezone=True), default=datetime.utcnow) + changed_by = Column(String(255), nullable=False) + + graft = relationship("Graft", back_populates="history") + + __table_args__ = ( + Index("idx_graft_history_graft_id", "graft_id"), + ) + + +class Harvest(Base): + __tablename__ = "harvests" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + fruit_id = Column(String(64), ForeignKey("fruits.id"), nullable=False) + tree_id = Column(UUID(as_uuid=True), ForeignKey("trees.id"), nullable=False) + original_name = Column(String(1024)) + harvested_at = Column(DateTime(timezone=True), default=datetime.utcnow) + harvested_by = Column(String(255), nullable=False) + source_ip = Column(String(45)) + + fruit = relationship("Fruit", back_populates="harvests") + tree = relationship("Tree", back_populates="harvests") + + __table_args__ = ( + Index("idx_harvests_fruit_id", "fruit_id"), + Index("idx_harvests_tree_id", "tree_id"), + Index("idx_harvests_harvested_at", "harvested_at"), + ) + + +class Consumer(Base): + __tablename__ = "consumers" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + tree_id = Column(UUID(as_uuid=True), ForeignKey("trees.id", ondelete="CASCADE"), nullable=False) + project_url = Column(String(2048), nullable=False) + last_access = Column(DateTime(timezone=True), default=datetime.utcnow) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + + tree = relationship("Tree", back_populates="consumers") + + __table_args__ = ( + Index("idx_consumers_tree_id", "tree_id"), + Index("idx_consumers_last_access", "last_access"), + ) + + +class AccessPermission(Base): + __tablename__ = "access_permissions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + grove_id = Column(UUID(as_uuid=True), ForeignKey("groves.id", ondelete="CASCADE"), nullable=False) + user_id = Column(String(255), nullable=False) + level = Column(String(20), nullable=False) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + expires_at = Column(DateTime(timezone=True)) + + grove = relationship("Grove", back_populates="permissions") + + __table_args__ = ( + CheckConstraint("level IN ('read', 'write', 'admin')", name="check_level"), + Index("idx_access_permissions_grove_id", "grove_id"), + Index("idx_access_permissions_user_id", "user_id"), + ) + + +class APIKey(Base): + __tablename__ = "api_keys" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + key_hash = Column(String(64), unique=True, nullable=False) + name = Column(String(255), nullable=False) + user_id = Column(String(255), nullable=False) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + expires_at = Column(DateTime(timezone=True)) + last_used = Column(DateTime(timezone=True)) + + __table_args__ = ( + Index("idx_api_keys_user_id", "user_id"), + Index("idx_api_keys_key_hash", "key_hash"), + ) + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + action = Column(String(100), nullable=False) + resource = Column(String(1024), nullable=False) + user_id = Column(String(255), nullable=False) + details = Column(JSON) + timestamp = Column(DateTime(timezone=True), default=datetime.utcnow) + source_ip = Column(String(45)) + + __table_args__ = ( + Index("idx_audit_logs_action", "action"), + Index("idx_audit_logs_resource", "resource"), + Index("idx_audit_logs_user_id", "user_id"), + Index("idx_audit_logs_timestamp", "timestamp"), + ) diff --git a/backend/app/routes.py b/backend/app/routes.py new file mode 100644 index 0000000..d948003 --- /dev/null +++ b/backend/app/routes.py @@ -0,0 +1,333 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Request +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session +from sqlalchemy import or_ +from typing import List, Optional +import re + +from .database import get_db +from .storage import get_storage, S3Storage +from .models import Grove, Tree, Fruit, Graft, Harvest, Consumer +from .schemas import ( + GroveCreate, GroveResponse, + TreeCreate, TreeResponse, + FruitResponse, + GraftCreate, GraftResponse, + CultivateResponse, + ConsumerResponse, + HealthResponse, +) + +router = APIRouter() + + +def get_user_id(request: Request) -> str: + """Extract user ID from request (simplified for now)""" + api_key = request.headers.get("X-Orchard-API-Key") + if api_key: + return "api-user" + auth = request.headers.get("Authorization") + if auth: + return "bearer-user" + return "anonymous" + + +# Health check +@router.get("/health", response_model=HealthResponse) +def health_check(): + return HealthResponse(status="ok") + + +# Grove routes +@router.get("/api/v1/groves", response_model=List[GroveResponse]) +def list_groves(request: Request, db: Session = Depends(get_db)): + user_id = get_user_id(request) + groves = db.query(Grove).filter( + or_(Grove.is_public == True, Grove.created_by == user_id) + ).order_by(Grove.name).all() + return groves + + +@router.post("/api/v1/groves", response_model=GroveResponse) +def create_grove(grove: GroveCreate, request: Request, db: Session = Depends(get_db)): + user_id = get_user_id(request) + + existing = db.query(Grove).filter(Grove.name == grove.name).first() + if existing: + raise HTTPException(status_code=400, detail="Grove already exists") + + db_grove = Grove( + name=grove.name, + description=grove.description, + is_public=grove.is_public, + created_by=user_id, + ) + db.add(db_grove) + db.commit() + db.refresh(db_grove) + return db_grove + + +@router.get("/api/v1/groves/{grove_name}", response_model=GroveResponse) +def get_grove(grove_name: str, db: Session = Depends(get_db)): + grove = db.query(Grove).filter(Grove.name == grove_name).first() + if not grove: + raise HTTPException(status_code=404, detail="Grove not found") + return grove + + +# Tree routes +@router.get("/api/v1/grove/{grove_name}/trees", response_model=List[TreeResponse]) +def list_trees(grove_name: str, db: Session = Depends(get_db)): + grove = db.query(Grove).filter(Grove.name == grove_name).first() + if not grove: + raise HTTPException(status_code=404, detail="Grove not found") + + trees = db.query(Tree).filter(Tree.grove_id == grove.id).order_by(Tree.name).all() + return trees + + +@router.post("/api/v1/grove/{grove_name}/trees", response_model=TreeResponse) +def create_tree(grove_name: str, tree: TreeCreate, db: Session = Depends(get_db)): + grove = db.query(Grove).filter(Grove.name == grove_name).first() + if not grove: + raise HTTPException(status_code=404, detail="Grove not found") + + existing = db.query(Tree).filter(Tree.grove_id == grove.id, Tree.name == tree.name).first() + if existing: + raise HTTPException(status_code=400, detail="Tree already exists in this grove") + + db_tree = Tree( + grove_id=grove.id, + name=tree.name, + description=tree.description, + ) + db.add(db_tree) + db.commit() + db.refresh(db_tree) + return db_tree + + +# Cultivate (upload) +@router.post("/api/v1/grove/{grove_name}/{tree_name}/cultivate", response_model=CultivateResponse) +def cultivate( + grove_name: str, + tree_name: str, + request: Request, + file: UploadFile = File(...), + tag: Optional[str] = Form(None), + db: Session = Depends(get_db), + storage: S3Storage = Depends(get_storage), +): + user_id = get_user_id(request) + + # Get grove and tree + grove = db.query(Grove).filter(Grove.name == grove_name).first() + if not grove: + raise HTTPException(status_code=404, detail="Grove not found") + + tree = db.query(Tree).filter(Tree.grove_id == grove.id, Tree.name == tree_name).first() + if not tree: + raise HTTPException(status_code=404, detail="Tree not found") + + # Store file + sha256_hash, size, s3_key = storage.store(file.file) + + # Create or update fruit record + fruit = db.query(Fruit).filter(Fruit.id == sha256_hash).first() + if fruit: + fruit.ref_count += 1 + else: + fruit = Fruit( + id=sha256_hash, + size=size, + content_type=file.content_type, + original_name=file.filename, + created_by=user_id, + s3_key=s3_key, + ) + db.add(fruit) + + # Record harvest + harvest = Harvest( + fruit_id=sha256_hash, + tree_id=tree.id, + original_name=file.filename, + harvested_by=user_id, + source_ip=request.client.host if request.client else None, + ) + db.add(harvest) + + # Create tag if provided + if tag: + existing_graft = db.query(Graft).filter(Graft.tree_id == tree.id, Graft.name == tag).first() + if existing_graft: + existing_graft.fruit_id = sha256_hash + existing_graft.created_by = user_id + else: + graft = Graft( + tree_id=tree.id, + name=tag, + fruit_id=sha256_hash, + created_by=user_id, + ) + db.add(graft) + + db.commit() + + return CultivateResponse( + fruit_id=sha256_hash, + size=size, + grove=grove_name, + tree=tree_name, + tag=tag, + ) + + +# Harvest (download) +@router.get("/api/v1/grove/{grove_name}/{tree_name}/+/{ref}") +def harvest( + grove_name: str, + tree_name: str, + ref: str, + db: Session = Depends(get_db), + storage: S3Storage = Depends(get_storage), +): + # Get grove and tree + grove = db.query(Grove).filter(Grove.name == grove_name).first() + if not grove: + raise HTTPException(status_code=404, detail="Grove not found") + + tree = db.query(Tree).filter(Tree.grove_id == grove.id, Tree.name == tree_name).first() + if not tree: + raise HTTPException(status_code=404, detail="Tree not found") + + # Resolve reference to fruit + fruit = None + + # Check for explicit prefixes + if ref.startswith("fruit:"): + fruit_id = ref[6:] + fruit = db.query(Fruit).filter(Fruit.id == fruit_id).first() + elif ref.startswith("tag:") or ref.startswith("version:"): + tag_name = ref.split(":", 1)[1] + graft = db.query(Graft).filter(Graft.tree_id == tree.id, Graft.name == tag_name).first() + if graft: + fruit = db.query(Fruit).filter(Fruit.id == graft.fruit_id).first() + else: + # Try as tag name first + graft = db.query(Graft).filter(Graft.tree_id == tree.id, Graft.name == ref).first() + if graft: + fruit = db.query(Fruit).filter(Fruit.id == graft.fruit_id).first() + else: + # Try as direct fruit ID + fruit = db.query(Fruit).filter(Fruit.id == ref).first() + + if not fruit: + raise HTTPException(status_code=404, detail="Artifact not found") + + # Stream from S3 + stream = storage.get_stream(fruit.s3_key) + + filename = fruit.original_name or f"{fruit.id}" + + return StreamingResponse( + stream, + media_type=fruit.content_type or "application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +# Compatibility route +@router.get("/grove/{grove_name}/{tree_name}/+/{ref}") +def harvest_compat( + grove_name: str, + tree_name: str, + ref: str, + db: Session = Depends(get_db), + storage: S3Storage = Depends(get_storage), +): + return harvest(grove_name, tree_name, ref, db, storage) + + +# Graft routes +@router.get("/api/v1/grove/{grove_name}/{tree_name}/grafts", response_model=List[GraftResponse]) +def list_grafts(grove_name: str, tree_name: str, db: Session = Depends(get_db)): + grove = db.query(Grove).filter(Grove.name == grove_name).first() + if not grove: + raise HTTPException(status_code=404, detail="Grove not found") + + tree = db.query(Tree).filter(Tree.grove_id == grove.id, Tree.name == tree_name).first() + if not tree: + raise HTTPException(status_code=404, detail="Tree not found") + + grafts = db.query(Graft).filter(Graft.tree_id == tree.id).order_by(Graft.name).all() + return grafts + + +@router.post("/api/v1/grove/{grove_name}/{tree_name}/graft", response_model=GraftResponse) +def create_graft( + grove_name: str, + tree_name: str, + graft: GraftCreate, + request: Request, + db: Session = Depends(get_db), +): + user_id = get_user_id(request) + + grove = db.query(Grove).filter(Grove.name == grove_name).first() + if not grove: + raise HTTPException(status_code=404, detail="Grove not found") + + tree = db.query(Tree).filter(Tree.grove_id == grove.id, Tree.name == tree_name).first() + if not tree: + raise HTTPException(status_code=404, detail="Tree not found") + + # Verify fruit exists + fruit = db.query(Fruit).filter(Fruit.id == graft.fruit_id).first() + if not fruit: + raise HTTPException(status_code=404, detail="Fruit not found") + + # Create or update graft + existing = db.query(Graft).filter(Graft.tree_id == tree.id, Graft.name == graft.name).first() + if existing: + existing.fruit_id = graft.fruit_id + existing.created_by = user_id + db.commit() + db.refresh(existing) + return existing + + db_graft = Graft( + tree_id=tree.id, + name=graft.name, + fruit_id=graft.fruit_id, + created_by=user_id, + ) + db.add(db_graft) + db.commit() + db.refresh(db_graft) + return db_graft + + +# Consumer routes +@router.get("/api/v1/grove/{grove_name}/{tree_name}/consumers", response_model=List[ConsumerResponse]) +def get_consumers(grove_name: str, tree_name: str, db: Session = Depends(get_db)): + grove = db.query(Grove).filter(Grove.name == grove_name).first() + if not grove: + raise HTTPException(status_code=404, detail="Grove not found") + + tree = db.query(Tree).filter(Tree.grove_id == grove.id, Tree.name == tree_name).first() + if not tree: + raise HTTPException(status_code=404, detail="Tree not found") + + consumers = db.query(Consumer).filter(Consumer.tree_id == tree.id).order_by(Consumer.last_access.desc()).all() + return consumers + + +# Fruit by ID +@router.get("/api/v1/fruit/{fruit_id}", response_model=FruitResponse) +def get_fruit(fruit_id: str, db: Session = Depends(get_db)): + fruit = db.query(Fruit).filter(Fruit.id == fruit_id).first() + if not fruit: + raise HTTPException(status_code=404, detail="Fruit not found") + return fruit diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..087ee2f --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,101 @@ +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel +from uuid import UUID + + +# Grove schemas +class GroveCreate(BaseModel): + name: str + description: Optional[str] = None + is_public: bool = True + + +class GroveResponse(BaseModel): + id: UUID + name: str + description: Optional[str] + is_public: bool + created_at: datetime + updated_at: datetime + created_by: str + + class Config: + from_attributes = True + + +# Tree schemas +class TreeCreate(BaseModel): + name: str + description: Optional[str] = None + + +class TreeResponse(BaseModel): + id: UUID + grove_id: UUID + name: str + description: Optional[str] + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# Fruit schemas +class FruitResponse(BaseModel): + id: str + size: int + content_type: Optional[str] + original_name: Optional[str] + created_at: datetime + created_by: str + ref_count: int + + class Config: + from_attributes = True + + +# Graft schemas +class GraftCreate(BaseModel): + name: str + fruit_id: str + + +class GraftResponse(BaseModel): + id: UUID + tree_id: UUID + name: str + fruit_id: str + created_at: datetime + created_by: str + + class Config: + from_attributes = True + + +# Cultivate response (upload) +class CultivateResponse(BaseModel): + fruit_id: str + size: int + grove: str + tree: str + tag: Optional[str] + + +# Consumer schemas +class ConsumerResponse(BaseModel): + id: UUID + tree_id: UUID + project_url: str + last_access: datetime + created_at: datetime + + class Config: + from_attributes = True + + +# Health check +class HealthResponse(BaseModel): + status: str + version: str = "1.0.0" diff --git a/backend/app/storage.py b/backend/app/storage.py new file mode 100644 index 0000000..4400b50 --- /dev/null +++ b/backend/app/storage.py @@ -0,0 +1,83 @@ +import hashlib +from typing import BinaryIO, Tuple +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError + +from .config import get_settings + +settings = get_settings() + + +class S3Storage: + def __init__(self): + config = Config(s3={"addressing_style": "path"} if settings.s3_use_path_style else {}) + + self.client = boto3.client( + "s3", + endpoint_url=settings.s3_endpoint if settings.s3_endpoint else None, + region_name=settings.s3_region, + aws_access_key_id=settings.s3_access_key_id, + aws_secret_access_key=settings.s3_secret_access_key, + config=config, + ) + self.bucket = settings.s3_bucket + + def store(self, file: BinaryIO) -> Tuple[str, int]: + """ + Store a file and return its SHA256 hash and size. + Content-addressable: if the file already exists, just return the hash. + """ + # Read file and compute hash + content = file.read() + sha256_hash = hashlib.sha256(content).hexdigest() + size = len(content) + + # Check if already exists + s3_key = f"fruits/{sha256_hash[:2]}/{sha256_hash[2:4]}/{sha256_hash}" + + if not self._exists(s3_key): + self.client.put_object( + Bucket=self.bucket, + Key=s3_key, + Body=content, + ) + + return sha256_hash, size, s3_key + + def get(self, s3_key: str) -> bytes: + """Retrieve a file by its S3 key""" + response = self.client.get_object(Bucket=self.bucket, Key=s3_key) + return response["Body"].read() + + def get_stream(self, s3_key: str): + """Get a streaming response for a file""" + response = self.client.get_object(Bucket=self.bucket, Key=s3_key) + return response["Body"] + + def _exists(self, s3_key: str) -> bool: + """Check if an object exists""" + try: + self.client.head_object(Bucket=self.bucket, Key=s3_key) + return True + except ClientError: + return False + + def delete(self, s3_key: str) -> bool: + """Delete an object""" + try: + self.client.delete_object(Bucket=self.bucket, Key=s3_key) + return True + except ClientError: + return False + + +# Singleton instance +_storage = None + + +def get_storage() -> S3Storage: + global _storage + if _storage is None: + _storage = S3Storage() + return _storage diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..73e6ebe --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +sqlalchemy==2.0.25 +psycopg2-binary==2.9.9 +alembic==1.13.1 +boto3==1.34.25 +python-multipart==0.0.6 +pydantic==2.5.3 +pydantic-settings==2.1.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 diff --git a/cmd/orchard-server/main.go b/cmd/orchard-server/main.go deleted file mode 100644 index fae71c8..0000000 --- a/cmd/orchard-server/main.go +++ /dev/null @@ -1,85 +0,0 @@ -package main - -import ( - "context" - "fmt" - "net/http" - "os" - "os/signal" - "syscall" - "time" - - "gitlab.global.bsf.tools/esv/bsf/bsf-integration/orchard/orchard-mvp/internal/api" - "gitlab.global.bsf.tools/esv/bsf/bsf-integration/orchard/orchard-mvp/internal/config" - "gitlab.global.bsf.tools/esv/bsf/bsf-integration/orchard/orchard-mvp/internal/storage" - "go.uber.org/zap" -) - -func main() { - // Initialize logger - logger, err := zap.NewProduction() - if err != nil { - fmt.Fprintf(os.Stderr, "failed to create logger: %v\n", err) - os.Exit(1) - } - defer logger.Sync() - - // Load configuration - cfg, err := config.Load() - if err != nil { - logger.Fatal("failed to load configuration", zap.Error(err)) - } - - // Initialize database - db, err := storage.NewDatabase(&cfg.Database) - if err != nil { - logger.Fatal("failed to connect to database", zap.Error(err)) - } - defer db.Close() - - // Initialize S3 storage - s3, err := storage.NewS3Storage(&cfg.S3) - if err != nil { - logger.Fatal("failed to initialize S3 storage", zap.Error(err)) - } - - // Setup router - router := api.SetupRouter(db, s3, logger) - - // Create HTTP server - addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) - srv := &http.Server{ - Addr: addr, - Handler: router, - ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, - IdleTimeout: 60 * time.Second, - } - - // Start server in goroutine - go func() { - logger.Info("starting Orchard server", - zap.String("address", addr), - ) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Fatal("server failed", zap.Error(err)) - } - }() - - // Wait for interrupt signal - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit - - logger.Info("shutting down server...") - - // Graceful shutdown with timeout - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - if err := srv.Shutdown(ctx); err != nil { - logger.Fatal("server forced to shutdown", zap.Error(err)) - } - - logger.Info("server stopped") -} diff --git a/config.yaml b/config.yaml deleted file mode 100644 index 454c0e7..0000000 --- a/config.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# Orchard Server Configuration -# This file can be overridden by environment variables prefixed with ORCHARD_ - -server: - host: "0.0.0.0" - port: 8080 - -database: - host: "localhost" - port: 5432 - user: "orchard" - password: "orchard_secret" - dbname: "orchard" - sslmode: "disable" - -s3: - endpoint: "http://localhost:9000" - region: "us-east-1" - bucket: "orchard-artifacts" - access_key_id: "minioadmin" - secret_access_key: "minioadmin" - use_path_style: true - -redis: - host: "localhost" - port: 6379 - password: "" - db: 0 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b14dd1b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Orchard - Content-Addressable Storage + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1a09f8c --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "orchard-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.3" + }, + "devDependencies": { + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.3.3", + "vite": "^5.0.12" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..dbc7370 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,19 @@ +import { Routes, Route } from 'react-router-dom'; +import Layout from './components/Layout'; +import Home from './pages/Home'; +import GrovePage from './pages/GrovePage'; +import TreePage from './pages/TreePage'; + +function App() { + return ( + + + } /> + } /> + } /> + + + ); +} + +export default App; diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..9103536 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,87 @@ +import { Grove, Tree, Graft, Fruit, CultivateResponse } from './types'; + +const API_BASE = '/api/v1'; + +async function handleResponse(response: Response): Promise { + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new Error(error.detail || `HTTP ${response.status}`); + } + return response.json(); +} + +// Grove API +export async function listGroves(): Promise { + const response = await fetch(`${API_BASE}/groves`); + return handleResponse(response); +} + +export async function createGrove(data: { name: string; description?: string; is_public?: boolean }): Promise { + const response = await fetch(`${API_BASE}/groves`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return handleResponse(response); +} + +export async function getGrove(name: string): Promise { + const response = await fetch(`${API_BASE}/groves/${name}`); + return handleResponse(response); +} + +// Tree API +export async function listTrees(groveName: string): Promise { + const response = await fetch(`${API_BASE}/grove/${groveName}/trees`); + return handleResponse(response); +} + +export async function createTree(groveName: string, data: { name: string; description?: string }): Promise { + const response = await fetch(`${API_BASE}/grove/${groveName}/trees`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return handleResponse(response); +} + +// Graft API +export async function listGrafts(groveName: string, treeName: string): Promise { + const response = await fetch(`${API_BASE}/grove/${groveName}/${treeName}/grafts`); + return handleResponse(response); +} + +export async function createGraft(groveName: string, treeName: string, data: { name: string; fruit_id: string }): Promise { + const response = await fetch(`${API_BASE}/grove/${groveName}/${treeName}/graft`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return handleResponse(response); +} + +// Fruit API +export async function getFruit(fruitId: string): Promise { + const response = await fetch(`${API_BASE}/fruit/${fruitId}`); + return handleResponse(response); +} + +// Upload +export async function cultivate(groveName: string, treeName: string, file: File, tag?: string): Promise { + const formData = new FormData(); + formData.append('file', file); + if (tag) { + formData.append('tag', tag); + } + + const response = await fetch(`${API_BASE}/grove/${groveName}/${treeName}/cultivate`, { + method: 'POST', + body: formData, + }); + return handleResponse(response); +} + +// Download URL +export function getDownloadUrl(groveName: string, treeName: string, ref: string): string { + return `${API_BASE}/grove/${groveName}/${treeName}/+/${ref}`; +} diff --git a/frontend/src/components/Layout.css b/frontend/src/components/Layout.css new file mode 100644 index 0000000..87606d3 --- /dev/null +++ b/frontend/src/components/Layout.css @@ -0,0 +1,65 @@ +.layout { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.header { + background-color: var(--primary); + color: white; + padding: 1rem 0; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.header-content { + display: flex; + justify-content: space-between; + align-items: center; +} + +.logo { + display: flex; + align-items: center; + gap: 0.5rem; + color: white; + font-size: 1.5rem; + font-weight: bold; + text-decoration: none; +} + +.logo:hover { + text-decoration: none; +} + +.logo-icon { + font-size: 2rem; +} + +.nav { + display: flex; + gap: 1.5rem; +} + +.nav a { + color: white; + opacity: 0.9; +} + +.nav a:hover { + opacity: 1; + text-decoration: none; +} + +.main { + flex: 1; + padding: 2rem 0; +} + +.footer { + background-color: var(--primary-dark); + color: white; + padding: 1rem 0; + text-align: center; + opacity: 0.9; + font-size: 0.875rem; +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..e4672d5 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,37 @@ +import { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import './Layout.css'; + +interface LayoutProps { + children: ReactNode; +} + +function Layout({ children }: LayoutProps) { + return ( +
+
+
+ + 🌳 + Orchard + + +
+
+
+
+ {children} +
+
+
+
+

Orchard - Content-Addressable Storage System

+
+
+
+ ); +} + +export default Layout; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..167a64c --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,47 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --primary: #2d5a27; + --primary-light: #4a8c3f; + --primary-dark: #1e3d1a; + --secondary: #8b4513; + --background: #f5f5f0; + --surface: #ffffff; + --text: #333333; + --text-light: #666666; + --border: #e0e0e0; + --success: #28a745; + --error: #dc3545; + --warning: #ffc107; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background-color: var(--background); + color: var(--text); + line-height: 1.6; +} + +a { + color: var(--primary); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +button { + cursor: pointer; + font-family: inherit; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..fa94fac --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git a/frontend/src/pages/GrovePage.tsx b/frontend/src/pages/GrovePage.tsx new file mode 100644 index 0000000..78bd90a --- /dev/null +++ b/frontend/src/pages/GrovePage.tsx @@ -0,0 +1,134 @@ +import { useState, useEffect } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { Grove, Tree } from '../types'; +import { getGrove, listTrees, createTree } from '../api'; +import './Home.css'; + +function GrovePage() { + const { groveName } = useParams<{ groveName: string }>(); + const [grove, setGrove] = useState(null); + const [trees, setTrees] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showForm, setShowForm] = useState(false); + const [newTree, setNewTree] = useState({ name: '', description: '' }); + const [creating, setCreating] = useState(false); + + useEffect(() => { + if (groveName) { + loadData(); + } + }, [groveName]); + + async function loadData() { + try { + setLoading(true); + const [groveData, treesData] = await Promise.all([ + getGrove(groveName!), + listTrees(groveName!), + ]); + setGrove(groveData); + setTrees(treesData); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load data'); + } finally { + setLoading(false); + } + } + + async function handleCreateTree(e: React.FormEvent) { + e.preventDefault(); + try { + setCreating(true); + await createTree(groveName!, newTree); + setNewTree({ name: '', description: '' }); + setShowForm(false); + loadData(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create tree'); + } finally { + setCreating(false); + } + } + + if (loading) { + return
Loading...
; + } + + if (!grove) { + return
Grove not found
; + } + + return ( +
+ + +
+
+

{grove.name}

+ {grove.description &&

{grove.description}

} +
+ +
+ + {error &&
{error}
} + + {showForm && ( +
+

Create New Tree

+
+ + setNewTree({ ...newTree, name: e.target.value })} + placeholder="releases" + required + /> +
+
+ + setNewTree({ ...newTree, description: e.target.value })} + placeholder="Optional description" + /> +
+ +
+ )} + + {trees.length === 0 ? ( +
+

No trees yet. Create your first tree to start uploading artifacts!

+
+ ) : ( +
+ {trees.map((tree) => ( + +

📦 {tree.name}

+ {tree.description &&

{tree.description}

} +
+ + Created {new Date(tree.created_at).toLocaleDateString()} + +
+ + ))} +
+ )} +
+ ); +} + +export default GrovePage; diff --git a/frontend/src/pages/Home.css b/frontend/src/pages/Home.css new file mode 100644 index 0000000..c769647 --- /dev/null +++ b/frontend/src/pages/Home.css @@ -0,0 +1,186 @@ +.home { + max-width: 1000px; + margin: 0 auto; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.page-header h1 { + font-size: 2rem; + color: var(--primary-dark); +} + +.btn { + padding: 0.625rem 1.25rem; + border: none; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s; +} + +.btn-primary { + background-color: var(--primary); + color: white; +} + +.btn-primary:hover { + background-color: var(--primary-dark); +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-secondary { + background-color: var(--border); + color: var(--text); +} + +.btn-secondary:hover { + background-color: #d0d0d0; +} + +.card { + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.5rem; +} + +.form { + margin-bottom: 2rem; +} + +.form h3 { + margin-bottom: 1rem; + color: var(--primary-dark); +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.375rem; + font-weight: 500; + color: var(--text-light); + font-size: 0.875rem; +} + +.form-group input[type="text"], +.form-group input[type="file"], +.form-group select, +.form-group textarea { + width: 100%; + padding: 0.625rem; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.875rem; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(45, 90, 39, 0.1); +} + +.form-group.checkbox label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +.form-group.checkbox input { + width: auto; +} + +.error-message { + background-color: #fef2f2; + border: 1px solid #fecaca; + color: var(--error); + padding: 0.75rem 1rem; + border-radius: 6px; + margin-bottom: 1rem; +} + +.loading { + text-align: center; + padding: 3rem; + color: var(--text-light); +} + +.empty-state { + text-align: center; + padding: 3rem; + color: var(--text-light); + background-color: var(--surface); + border: 1px dashed var(--border); + border-radius: 8px; +} + +.grove-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; +} + +.grove-card { + display: block; + color: inherit; + transition: transform 0.2s, box-shadow 0.2s; +} + +.grove-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + text-decoration: none; +} + +.grove-card h3 { + color: var(--primary); + margin-bottom: 0.5rem; +} + +.grove-card p { + color: var(--text-light); + font-size: 0.875rem; + margin-bottom: 1rem; +} + +.grove-meta { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.75rem; +} + +.badge { + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-weight: 500; +} + +.badge-public { + background-color: #dcfce7; + color: #166534; +} + +.badge-private { + background-color: #fef3c7; + color: #92400e; +} + +.date { + color: var(--text-light); +} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx new file mode 100644 index 0000000..88ac463 --- /dev/null +++ b/frontend/src/pages/Home.tsx @@ -0,0 +1,128 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { Grove } from '../types'; +import { listGroves, createGrove } from '../api'; +import './Home.css'; + +function Home() { + const [groves, setGroves] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showForm, setShowForm] = useState(false); + const [newGrove, setNewGrove] = useState({ name: '', description: '', is_public: true }); + const [creating, setCreating] = useState(false); + + useEffect(() => { + loadGroves(); + }, []); + + async function loadGroves() { + try { + setLoading(true); + const data = await listGroves(); + setGroves(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load groves'); + } finally { + setLoading(false); + } + } + + async function handleCreateGrove(e: React.FormEvent) { + e.preventDefault(); + try { + setCreating(true); + await createGrove(newGrove); + setNewGrove({ name: '', description: '', is_public: true }); + setShowForm(false); + loadGroves(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create grove'); + } finally { + setCreating(false); + } + } + + if (loading) { + return
Loading groves...
; + } + + return ( +
+
+

Groves

+ +
+ + {error &&
{error}
} + + {showForm && ( +
+

Create New Grove

+
+ + setNewGrove({ ...newGrove, name: e.target.value })} + placeholder="my-project" + required + /> +
+
+ + setNewGrove({ ...newGrove, description: e.target.value })} + placeholder="Optional description" + /> +
+
+ +
+ +
+ )} + + {groves.length === 0 ? ( +
+

No groves yet. Create your first grove to get started!

+
+ ) : ( +
+ {groves.map((grove) => ( + +

{grove.name}

+ {grove.description &&

{grove.description}

} +
+ + {grove.is_public ? 'Public' : 'Private'} + + + Created {new Date(grove.created_at).toLocaleDateString()} + +
+ + ))} +
+ )} +
+ ); +} + +export default Home; diff --git a/frontend/src/pages/TreePage.css b/frontend/src/pages/TreePage.css new file mode 100644 index 0000000..857dfa8 --- /dev/null +++ b/frontend/src/pages/TreePage.css @@ -0,0 +1,131 @@ +.breadcrumb { + margin-bottom: 1rem; + font-size: 0.875rem; + color: var(--text-light); +} + +.breadcrumb a { + color: var(--primary); +} + +.breadcrumb span { + color: var(--text); + font-weight: 500; +} + +.description { + color: var(--text-light); + margin-top: 0.25rem; +} + +.success-message { + background-color: #f0fdf4; + border: 1px solid #bbf7d0; + color: var(--success); + padding: 0.75rem 1rem; + border-radius: 6px; + margin-bottom: 1rem; +} + +.upload-section { + margin-bottom: 2rem; +} + +.upload-section h3 { + margin-bottom: 1rem; + color: var(--primary-dark); +} + +.upload-form { + display: flex; + gap: 1rem; + align-items: flex-end; + flex-wrap: wrap; +} + +.upload-form .form-group { + margin-bottom: 0; + flex: 1; + min-width: 200px; +} + +h2 { + margin-bottom: 1rem; + color: var(--primary-dark); +} + +.grafts-table { + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + margin-bottom: 2rem; +} + +.grafts-table table { + width: 100%; + border-collapse: collapse; +} + +.grafts-table th, +.grafts-table td { + padding: 0.875rem 1rem; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.grafts-table th { + background-color: #f9f9f9; + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + color: var(--text-light); +} + +.grafts-table tr:last-child td { + border-bottom: none; +} + +.grafts-table tr:hover { + background-color: #f9f9f9; +} + +.fruit-id { + font-family: monospace; + font-size: 0.875rem; + color: var(--text-light); +} + +.btn-small { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; +} + +.usage-section { + margin-top: 2rem; +} + +.usage-section h3 { + margin-bottom: 0.5rem; + color: var(--primary-dark); +} + +.usage-section p { + color: var(--text-light); + margin-bottom: 0.5rem; + font-size: 0.875rem; +} + +.usage-section pre { + background-color: #1e1e1e; + color: #d4d4d4; + padding: 1rem; + border-radius: 6px; + overflow-x: auto; + margin-bottom: 1rem; +} + +.usage-section code { + font-family: 'Fira Code', 'Consolas', monospace; + font-size: 0.875rem; +} diff --git a/frontend/src/pages/TreePage.tsx b/frontend/src/pages/TreePage.tsx new file mode 100644 index 0000000..3795946 --- /dev/null +++ b/frontend/src/pages/TreePage.tsx @@ -0,0 +1,160 @@ +import { useState, useEffect, useRef } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { Graft } from '../types'; +import { listGrafts, cultivate, getDownloadUrl } from '../api'; +import './Home.css'; +import './TreePage.css'; + +function TreePage() { + const { groveName, treeName } = useParams<{ groveName: string; treeName: string }>(); + const [grafts, setGrafts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [uploading, setUploading] = useState(false); + const [uploadResult, setUploadResult] = useState(null); + const [tag, setTag] = useState(''); + const fileInputRef = useRef(null); + + useEffect(() => { + if (groveName && treeName) { + loadGrafts(); + } + }, [groveName, treeName]); + + async function loadGrafts() { + try { + setLoading(true); + const data = await listGrafts(groveName!, treeName!); + setGrafts(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load grafts'); + } finally { + setLoading(false); + } + } + + async function handleUpload(e: React.FormEvent) { + e.preventDefault(); + const file = fileInputRef.current?.files?.[0]; + if (!file) { + setError('Please select a file'); + return; + } + + try { + setUploading(true); + setError(null); + const result = await cultivate(groveName!, treeName!, file, tag || undefined); + setUploadResult(`Uploaded successfully! Fruit ID: ${result.fruit_id}`); + setTag(''); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + loadGrafts(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Upload failed'); + } finally { + setUploading(false); + } + } + + if (loading) { + return
Loading...
; + } + + return ( +
+ + +
+

📦 {treeName}

+
+ + {error &&
{error}
} + {uploadResult &&
{uploadResult}
} + +
+

Upload Artifact

+
+
+ + +
+
+ + setTag(e.target.value)} + placeholder="v1.0.0, latest, stable..." + /> +
+ +
+
+ +

Tags / Versions

+ {grafts.length === 0 ? ( +
+

No tags yet. Upload an artifact with a tag to create one!

+
+ ) : ( +
+ + + + + + + + + + + {grafts.map((graft) => ( + + + + + + + ))} + +
TagFruit IDCreatedActions
{graft.name}{graft.fruit_id.substring(0, 12)}...{new Date(graft.created_at).toLocaleString()} + + Download + +
+
+ )} + +
+

Usage

+

Download artifacts using:

+
+          curl -O {window.location.origin}/api/v1/grove/{groveName}/{treeName}/+/latest
+        
+

Or with a specific tag:

+
+          curl -O {window.location.origin}/api/v1/grove/{groveName}/{treeName}/+/v1.0.0
+        
+
+
+ ); +} + +export default TreePage; diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..18ca78e --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,53 @@ +export interface Grove { + id: string; + name: string; + description: string | null; + is_public: boolean; + created_at: string; + updated_at: string; + created_by: string; +} + +export interface Tree { + id: string; + grove_id: string; + name: string; + description: string | null; + created_at: string; + updated_at: string; +} + +export interface Fruit { + id: string; + size: number; + content_type: string | null; + original_name: string | null; + created_at: string; + created_by: string; + ref_count: number; +} + +export interface Graft { + id: string; + tree_id: string; + name: string; + fruit_id: string; + created_at: string; + created_by: string; +} + +export interface Consumer { + id: string; + tree_id: string; + project_url: string; + last_access: string; + created_at: string; +} + +export interface CultivateResponse { + fruit_id: string; + size: number; + grove: string; + tree: string; + tag: string | null; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..3934b8f --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..957c9c5 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': 'http://localhost:8080', + '/health': 'http://localhost:8080', + '/grove': 'http://localhost:8080', + } + } +}) diff --git a/go.mod b/go.mod deleted file mode 100644 index ff79e48..0000000 --- a/go.mod +++ /dev/null @@ -1,70 +0,0 @@ -module gitlab.global.bsf.tools/esv/bsf/bsf-integration/orchard/orchard-mvp - -go 1.22 - -require ( - github.com/aws/aws-sdk-go-v2 v1.24.1 - github.com/aws/aws-sdk-go-v2/config v1.26.6 - github.com/aws/aws-sdk-go-v2/credentials v1.16.16 - github.com/aws/aws-sdk-go-v2/service/s3 v1.48.1 - github.com/gin-gonic/gin v1.9.1 - github.com/google/uuid v1.5.0 - github.com/lib/pq v1.10.9 - github.com/spf13/viper v1.18.2 - go.uber.org/zap v1.26.0 -) - -require ( - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.10 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.10 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect - github.com/aws/smithy-go v1.19.0 // indirect - github.com/bytedance/sonic v1.9.1 // indirect - github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.2 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.14.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.4 // indirect - github.com/leodido/go-urn v1.2.4 // indirect - github.com/magiconair/properties v1.8.7 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.1.0 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/subosito/gotenv v1.6.0 // indirect - github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.11 // indirect - go.uber.org/multierr v1.10.0 // indirect - golang.org/x/arch v0.3.0 // indirect - golang.org/x/crypto v0.16.0 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/net v0.19.0 // indirect - golang.org/x/sys v0.15.0 // indirect - golang.org/x/text v0.14.0 // indirect - google.golang.org/protobuf v1.31.0 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index b6b3d7d..0000000 --- a/go.sum +++ /dev/null @@ -1,171 +0,0 @@ -github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU= -github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo= -github.com/aws/aws-sdk-go-v2/config v1.26.6 h1:Z/7w9bUqlRI0FFQpetVuFYEsjzE3h7fpU6HuGmfPL/o= -github.com/aws/aws-sdk-go-v2/config v1.26.6/go.mod h1:uKU6cnDmYCvJ+pxO9S4cWDb2yWWIH5hra+32hVh1MI4= -github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8= -github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw= -github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 h1:n3GDfwqF2tzEkXlv5cuy4iy7LpKDtqDMcNLfZDu9rls= -github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10 h1:5oE2WzJE56/mVveuDZPJESKlg/00AaS2pY2QZcnxg4M= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10/go.mod h1:FHbKWQtRBYUz4vO5WBWjzMD2by126ny5y/1EoaWoLfI= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.10 h1:L0ai8WICYHozIKK+OtPzVJBugL7culcuM4E4JOpIEm8= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.10/go.mod h1:byqfyxJBshFk0fF9YmK0M0ugIO8OWjzH2T3bPG4eGuA= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.10 h1:KOxnQeWy5sXyS37fdKEvAsGHOr9fa/qvwxfJurR/BzE= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.10/go.mod h1:jMx5INQFYFYB3lQD9W0D8Ohgq6Wnl7NYOJ2TQndbulI= -github.com/aws/aws-sdk-go-v2/service/s3 v1.48.1 h1:5XNlsBsEvBZBMO6p82y+sqpWg8j5aBCe+5C2GBFgqBQ= -github.com/aws/aws-sdk-go-v2/service/s3 v1.48.1/go.mod h1:4qXHrG1Ne3VGIMZPCB8OjH/pLFO94sKABIusjh0KWPU= -github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 h1:eajuO3nykDPdYicLlP3AGgOyVN3MOlFmZv7WGTuJPow= -github.com/aws/aws-sdk-go-v2/service/sso v1.18.7/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w1NmfkfiSK8mS4zOx3BA= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8= -github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0= -github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U= -github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= -github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= -github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= -github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= -github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= -github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= -github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= -github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= -github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= -github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= -github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= -github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= -github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= -github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= -github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= -github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= -github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= -github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= -github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= -github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= -go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= -go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= -golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= -golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= -golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= -golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= -golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/api/handlers.go b/internal/api/handlers.go deleted file mode 100644 index 8890e2e..0000000 --- a/internal/api/handlers.go +++ /dev/null @@ -1,442 +0,0 @@ -package api - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - - "gitlab.global.bsf.tools/esv/bsf/bsf-integration/orchard/orchard-mvp/internal/models" - "gitlab.global.bsf.tools/esv/bsf/bsf-integration/orchard/orchard-mvp/internal/storage" - "github.com/gin-gonic/gin" - "go.uber.org/zap" -) - -type Handler struct { - db *storage.Database - s3 *storage.S3Storage - logger *zap.Logger -} - -func NewHandler(db *storage.Database, s3 *storage.S3Storage, logger *zap.Logger) *Handler { - return &Handler{ - db: db, - s3: s3, - logger: logger, - } -} - -// Health check endpoint -func (h *Handler) Health(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "healthy"}) -} - -// Grove handlers - -func (h *Handler) CreateGrove(c *gin.Context) { - var grove models.Grove - if err := c.ShouldBindJSON(&grove); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - grove.CreatedBy = c.GetString("user_id") - if err := h.db.CreateGrove(c.Request.Context(), &grove); err != nil { - h.logger.Error("failed to create grove", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create grove"}) - return - } - - h.logAudit(c, "create_grove", grove.Name) - c.JSON(http.StatusCreated, grove) -} - -func (h *Handler) GetGrove(c *gin.Context) { - groveName := c.Param("grove") - - grove, err := h.db.GetGrove(c.Request.Context(), groveName) - if err != nil { - h.logger.Error("failed to get grove", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get grove"}) - return - } - - if grove == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) - return - } - - c.JSON(http.StatusOK, grove) -} - -func (h *Handler) ListGroves(c *gin.Context) { - userID := c.GetString("user_id") - - groves, err := h.db.ListGroves(c.Request.Context(), userID) - if err != nil { - h.logger.Error("failed to list groves", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list groves"}) - return - } - - c.JSON(http.StatusOK, groves) -} - -// Tree handlers - -func (h *Handler) CreateTree(c *gin.Context) { - groveName := c.Param("grove") - - grove, err := h.db.GetGrove(c.Request.Context(), groveName) - if err != nil || grove == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) - return - } - - var tree models.Tree - if err := c.ShouldBindJSON(&tree); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - tree.GroveID = grove.ID - if err := h.db.CreateTree(c.Request.Context(), &tree); err != nil { - h.logger.Error("failed to create tree", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create tree"}) - return - } - - h.logAudit(c, "create_tree", fmt.Sprintf("%s/%s", groveName, tree.Name)) - c.JSON(http.StatusCreated, tree) -} - -func (h *Handler) ListTrees(c *gin.Context) { - groveName := c.Param("grove") - - grove, err := h.db.GetGrove(c.Request.Context(), groveName) - if err != nil || grove == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) - return - } - - trees, err := h.db.ListTrees(c.Request.Context(), grove.ID) - if err != nil { - h.logger.Error("failed to list trees", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list trees"}) - return - } - - c.JSON(http.StatusOK, trees) -} - -// Fruit handlers (content-addressable storage) - -func (h *Handler) Cultivate(c *gin.Context) { - groveName := c.Param("grove") - treeName := c.Param("tree") - - grove, err := h.db.GetGrove(c.Request.Context(), groveName) - if err != nil || grove == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) - return - } - - tree, err := h.db.GetTree(c.Request.Context(), grove.ID, treeName) - if err != nil || tree == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "tree not found"}) - return - } - - // Get the uploaded file - file, header, err := c.Request.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"}) - return - } - defer file.Close() - - // Store in S3 (content-addressable) - hash, size, err := h.s3.Store(c.Request.Context(), file) - if err != nil { - h.logger.Error("failed to store artifact", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store artifact"}) - return - } - - // Create fruit record - fruit := &models.Fruit{ - ID: hash, - Size: size, - ContentType: header.Header.Get("Content-Type"), - OriginalName: header.Filename, - CreatedBy: c.GetString("user_id"), - S3Key: fmt.Sprintf("%s/%s/%s", hash[:2], hash[2:4], hash), - } - - if err := h.db.CreateFruit(c.Request.Context(), fruit); err != nil { - h.logger.Error("failed to create fruit record", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create fruit record"}) - return - } - - // Create harvest record - harvest := &models.Harvest{ - FruitID: hash, - TreeID: tree.ID, - OriginalName: header.Filename, - HarvestedBy: c.GetString("user_id"), - SourceIP: c.ClientIP(), - } - - if err := h.db.CreateHarvest(c.Request.Context(), harvest); err != nil { - h.logger.Error("failed to create harvest record", zap.Error(err)) - } - - // Optionally create a graft (tag) if specified - tag := c.PostForm("tag") - if tag != "" { - graft := &models.Graft{ - TreeID: tree.ID, - Name: tag, - FruitID: hash, - CreatedBy: c.GetString("user_id"), - } - if err := h.db.CreateGraft(c.Request.Context(), graft); err != nil { - h.logger.Error("failed to create graft", zap.Error(err)) - } - } - - h.logAudit(c, "cultivate", fmt.Sprintf("%s/%s/%s", groveName, treeName, hash)) - - c.JSON(http.StatusCreated, gin.H{ - "fruit_id": hash, - "size": size, - "grove": groveName, - "tree": treeName, - "tag": tag, - }) -} - -func (h *Handler) Harvest(c *gin.Context) { - groveName := c.Param("grove") - treeName := c.Param("tree") - ref := c.Param("ref") - - grove, err := h.db.GetGrove(c.Request.Context(), groveName) - if err != nil || grove == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) - return - } - - tree, err := h.db.GetTree(c.Request.Context(), grove.ID, treeName) - if err != nil || tree == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "tree not found"}) - return - } - - // Resolve reference to fruit ID - var fruitID string - if strings.HasPrefix(ref, "fruit:") { - // Direct fruit reference - fruitID = strings.TrimPrefix(ref, "fruit:") - } else if strings.HasPrefix(ref, "version:") || strings.HasPrefix(ref, "tag:") { - // Tag/version reference - tagName := strings.TrimPrefix(ref, "version:") - tagName = strings.TrimPrefix(tagName, "tag:") - graft, err := h.db.GetGraft(c.Request.Context(), tree.ID, tagName) - if err != nil || graft == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "reference not found"}) - return - } - fruitID = graft.FruitID - } else { - // Assume it's a tag name - graft, err := h.db.GetGraft(c.Request.Context(), tree.ID, ref) - if err != nil || graft == nil { - // Maybe it's a direct fruit ID - fruit, err := h.db.GetFruit(c.Request.Context(), ref) - if err != nil || fruit == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "reference not found"}) - return - } - fruitID = ref - } else { - fruitID = graft.FruitID - } - } - - // Get fruit metadata - fruit, err := h.db.GetFruit(c.Request.Context(), fruitID) - if err != nil || fruit == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "fruit not found"}) - return - } - - // Track consumer if project URL is provided - projectURL := c.GetHeader("X-Orchard-Project") - if projectURL != "" { - h.db.TrackConsumer(c.Request.Context(), tree.ID, projectURL) - } - - // Stream content from S3 - reader, err := h.s3.Retrieve(c.Request.Context(), fruitID) - if err != nil { - h.logger.Error("failed to retrieve artifact", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve artifact"}) - return - } - defer reader.Close() - - h.logAudit(c, "harvest", fmt.Sprintf("%s/%s/%s", groveName, treeName, fruitID)) - - c.Header("Content-Type", fruit.ContentType) - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fruit.OriginalName)) - c.Header("X-Orchard-Fruit-ID", fruitID) - c.Header("X-Orchard-Size", fmt.Sprintf("%d", fruit.Size)) - - io.Copy(c.Writer, reader) -} - -func (h *Handler) GetFruit(c *gin.Context) { - fruitID := c.Param("fruit") - - fruit, err := h.db.GetFruit(c.Request.Context(), fruitID) - if err != nil { - h.logger.Error("failed to get fruit", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get fruit"}) - return - } - - if fruit == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "fruit not found"}) - return - } - - c.JSON(http.StatusOK, fruit) -} - -// Graft handlers (aliasing/tagging) - -func (h *Handler) CreateGraft(c *gin.Context) { - groveName := c.Param("grove") - treeName := c.Param("tree") - - grove, err := h.db.GetGrove(c.Request.Context(), groveName) - if err != nil || grove == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) - return - } - - tree, err := h.db.GetTree(c.Request.Context(), grove.ID, treeName) - if err != nil || tree == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "tree not found"}) - return - } - - var graft models.Graft - if err := c.ShouldBindJSON(&graft); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - graft.TreeID = tree.ID - graft.CreatedBy = c.GetString("user_id") - - // Verify fruit exists - fruit, err := h.db.GetFruit(c.Request.Context(), graft.FruitID) - if err != nil || fruit == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "fruit not found"}) - return - } - - if err := h.db.CreateGraft(c.Request.Context(), &graft); err != nil { - h.logger.Error("failed to create graft", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create graft"}) - return - } - - h.logAudit(c, "create_graft", fmt.Sprintf("%s/%s/%s", groveName, treeName, graft.Name)) - c.JSON(http.StatusCreated, graft) -} - -func (h *Handler) ListGrafts(c *gin.Context) { - groveName := c.Param("grove") - treeName := c.Param("tree") - - grove, err := h.db.GetGrove(c.Request.Context(), groveName) - if err != nil || grove == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) - return - } - - tree, err := h.db.GetTree(c.Request.Context(), grove.ID, treeName) - if err != nil || tree == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "tree not found"}) - return - } - - grafts, err := h.db.ListGrafts(c.Request.Context(), tree.ID) - if err != nil { - h.logger.Error("failed to list grafts", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list grafts"}) - return - } - - c.JSON(http.StatusOK, grafts) -} - -// Consumer tracking handlers - -func (h *Handler) GetConsumers(c *gin.Context) { - groveName := c.Param("grove") - treeName := c.Param("tree") - - grove, err := h.db.GetGrove(c.Request.Context(), groveName) - if err != nil || grove == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) - return - } - - tree, err := h.db.GetTree(c.Request.Context(), grove.ID, treeName) - if err != nil || tree == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "tree not found"}) - return - } - - consumers, err := h.db.GetConsumers(c.Request.Context(), tree.ID) - if err != nil { - h.logger.Error("failed to get consumers", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get consumers"}) - return - } - - c.JSON(http.StatusOK, consumers) -} - -// Search handler - -func (h *Handler) Search(c *gin.Context) { - // TODO: Implement format-aware search - c.JSON(http.StatusOK, gin.H{"message": "search not yet implemented"}) -} - -// Helper to log audit events -func (h *Handler) logAudit(c *gin.Context, action, resource string) { - details, _ := json.Marshal(map[string]string{ - "method": c.Request.Method, - "path": c.Request.URL.Path, - }) - - log := &models.AuditLog{ - Action: action, - Resource: resource, - UserID: c.GetString("user_id"), - Details: string(details), - SourceIP: c.ClientIP(), - } - - if err := h.db.CreateAuditLog(c.Request.Context(), log); err != nil { - h.logger.Error("failed to create audit log", zap.Error(err)) - } -} diff --git a/internal/api/router.go b/internal/api/router.go deleted file mode 100644 index 96d618f..0000000 --- a/internal/api/router.go +++ /dev/null @@ -1,153 +0,0 @@ -package api - -import ( - "embed" - "io/fs" - - "gitlab.global.bsf.tools/esv/bsf/bsf-integration/orchard/orchard-mvp/internal/storage" - "github.com/gin-gonic/gin" - "go.uber.org/zap" -) - -//go:embed static/* -var staticFiles embed.FS - -// SetupRouter configures all API routes -func SetupRouter(db *storage.Database, s3 *storage.S3Storage, logger *zap.Logger) *gin.Engine { - router := gin.New() - router.Use(gin.Recovery()) - router.Use(LoggerMiddleware(logger)) - - handler := NewHandler(db, s3, logger) - - // Serve static files from embedded FS - staticFS, _ := fs.Sub(staticFiles, "static") - - // Root - serve index.html - router.GET("/", func(c *gin.Context) { - data, err := fs.ReadFile(staticFS, "index.html") - if err != nil { - c.String(500, "Error loading page: "+err.Error()) - return - } - c.Data(200, "text/html; charset=utf-8", data) - }) - - // CSS file - router.GET("/static/style.css", func(c *gin.Context) { - data, err := fs.ReadFile(staticFS, "style.css") - if err != nil { - c.String(404, "Not found: "+err.Error()) - return - } - c.Data(200, "text/css; charset=utf-8", data) - }) - - // JS file - router.GET("/static/app.js", func(c *gin.Context) { - data, err := fs.ReadFile(staticFS, "app.js") - if err != nil { - c.String(404, "Not found: "+err.Error()) - return - } - c.Data(200, "application/javascript; charset=utf-8", data) - }) - - // Health check - router.GET("/health", handler.Health) - - // API v1 - v1 := router.Group("/api/v1") - { - // Authentication middleware - v1.Use(AuthMiddleware(db)) - - // Grove routes - groves := v1.Group("/groves") - { - groves.GET("", handler.ListGroves) - groves.POST("", handler.CreateGrove) - groves.GET("/:grove", handler.GetGrove) - } - - // Tree routes - trees := v1.Group("/grove/:grove/trees") - { - trees.GET("", handler.ListTrees) - trees.POST("", handler.CreateTree) - } - - // Fruit routes (content-addressable storage) - fruits := v1.Group("/grove/:grove/:tree") - { - // Upload artifact (cultivate) - fruits.POST("/cultivate", handler.Cultivate) - - // Download artifact (harvest) - fruits.GET("/+/:ref", handler.Harvest) - - // List grafts (tags/versions) - fruits.GET("/grafts", handler.ListGrafts) - - // Create graft (tag) - fruits.POST("/graft", handler.CreateGraft) - - // Get consumers - fruits.GET("/consumers", handler.GetConsumers) - } - - // Direct fruit access by hash - v1.GET("/fruit/:fruit", handler.GetFruit) - - // Search - v1.GET("/search", handler.Search) - } - - // Compatibility endpoint matching the URL pattern from spec - // /grove/{project}/{tree}/+/{ref} - router.GET("/grove/:grove/:tree/+/:ref", AuthMiddleware(db), handler.Harvest) - - return router -} - -// LoggerMiddleware logs requests -func LoggerMiddleware(logger *zap.Logger) gin.HandlerFunc { - return func(c *gin.Context) { - c.Next() - - logger.Info("request", - zap.String("method", c.Request.Method), - zap.String("path", c.Request.URL.Path), - zap.Int("status", c.Writer.Status()), - zap.String("client_ip", c.ClientIP()), - ) - } -} - -// AuthMiddleware handles authentication -func AuthMiddleware(db *storage.Database) gin.HandlerFunc { - return func(c *gin.Context) { - // Check for API key in header - apiKey := c.GetHeader("X-Orchard-API-Key") - if apiKey != "" { - // TODO: Validate API key against database - // For now, extract user ID from key - c.Set("user_id", "api-user") - c.Next() - return - } - - // Check for Bearer token - authHeader := c.GetHeader("Authorization") - if authHeader != "" { - // TODO: Implement OIDC/SAML validation - c.Set("user_id", "bearer-user") - c.Next() - return - } - - // Allow anonymous access for public groves (read only) - c.Set("user_id", "anonymous") - c.Next() - } -} diff --git a/internal/api/static/app.js b/internal/api/static/app.js deleted file mode 100644 index d208d60..0000000 --- a/internal/api/static/app.js +++ /dev/null @@ -1,573 +0,0 @@ -// Orchard Web UI - -const API_BASE = '/api/v1'; - -// State -let currentGrove = null; -let currentTree = null; - -// Initialize -document.addEventListener('DOMContentLoaded', () => { - setupNavigation(); - loadGroves(); -}); - -// Navigation -function setupNavigation() { - document.querySelectorAll('.nav-link').forEach(link => { - link.addEventListener('click', (e) => { - e.preventDefault(); - const view = link.dataset.view; - showView(view); - - document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active')); - link.classList.add('active'); - }); - }); -} - -function showView(viewName) { - document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); - document.getElementById(`${viewName}-view`).classList.add('active'); - - // Load data for view - if (viewName === 'groves') { - loadGroves(); - } else if (viewName === 'upload') { - loadGrovesForUpload(); - } -} - -// Groves -async function loadGroves() { - const container = document.getElementById('groves-list'); - container.innerHTML = '
Loading groves...
'; - - try { - const response = await fetch(`${API_BASE}/groves`); - const groves = await response.json(); - - if (!groves || groves.length === 0) { - container.innerHTML = ` -
-

No groves yet

-

Create your first grove to get started

-
- `; - return; - } - - container.innerHTML = groves.map(grove => ` -
-

- 🌳 - ${escapeHtml(grove.name)} - - ${grove.is_public ? 'Public' : 'Private'} - -

-

${escapeHtml(grove.description || 'No description')}

-
- Created ${formatDate(grove.created_at)} -
-
- `).join(''); - } catch (error) { - container.innerHTML = `

Error loading groves: ${error.message}

`; - } -} - -async function viewGrove(groveName) { - currentGrove = groveName; - document.getElementById('grove-detail-title').textContent = groveName; - - // Load grove info - try { - const response = await fetch(`${API_BASE}/groves/${groveName}`); - const grove = await response.json(); - - document.getElementById('grove-info').innerHTML = ` -
-
- - ${escapeHtml(grove.name)} -
-
- - - ${grove.is_public ? 'Public' : 'Private'} - -
-
- - ${formatDate(grove.created_at)} -
-
- - ${escapeHtml(grove.description || 'No description')} -
-
- `; - } catch (error) { - console.error('Error loading grove:', error); - } - - // Load trees - await loadTrees(groveName); - - // Show view - document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); - document.getElementById('grove-detail-view').classList.add('active'); -} - -async function loadTrees(groveName) { - const container = document.getElementById('trees-list'); - container.innerHTML = '
Loading trees...
'; - - try { - const response = await fetch(`${API_BASE}/grove/${groveName}/trees`); - const trees = await response.json(); - - if (!trees || trees.length === 0) { - container.innerHTML = ` -
-

No trees yet

-

Create a tree to store artifacts

-
- `; - return; - } - - container.innerHTML = trees.map(tree => ` -
-

- 🌲 - ${escapeHtml(tree.name)} -

-

${escapeHtml(tree.description || 'No description')}

-
- Created ${formatDate(tree.created_at)} -
-
- `).join(''); - } catch (error) { - container.innerHTML = `

Error loading trees: ${error.message}

`; - } -} - -async function viewTree(groveName, treeName) { - currentGrove = groveName; - currentTree = treeName; - - document.getElementById('tree-detail-title').textContent = `${groveName} / ${treeName}`; - - document.getElementById('tree-info').innerHTML = ` -
-
- - ${escapeHtml(groveName)} -
-
- - ${escapeHtml(treeName)} -
-
- `; - - // Load grafts - await loadGrafts(groveName, treeName); - - // Show view - document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); - document.getElementById('tree-detail-view').classList.add('active'); -} - -async function loadGrafts(groveName, treeName) { - const container = document.getElementById('grafts-list'); - container.innerHTML = '
Loading versions...
'; - - try { - const response = await fetch(`${API_BASE}/grove/${groveName}/${treeName}/grafts`); - const grafts = await response.json(); - - if (!grafts || grafts.length === 0) { - container.innerHTML = ` -
-

No versions yet

-

Upload an artifact to create the first version

-
- `; - return; - } - - container.innerHTML = ` - - - - - - - - - - - ${grafts.map(graft => ` - - - - - - - `).join('')} - -
TagFruit IDCreatedActions
${escapeHtml(graft.name)} - - ${graft.fruit_id.substring(0, 16)}... - - ${formatDate(graft.created_at)} - - Download - -
- `; - } catch (error) { - container.innerHTML = `

Error loading versions: ${error.message}

`; - } -} - -function backToGrove() { - if (currentGrove) { - viewGrove(currentGrove); - } else { - showView('groves'); - } -} - -// Create Grove -function showCreateGroveModal() { - document.getElementById('create-grove-modal').classList.remove('hidden'); - document.getElementById('grove-name').focus(); -} - -async function createGrove(e) { - e.preventDefault(); - - const name = document.getElementById('grove-name').value; - const description = document.getElementById('grove-description').value; - const isPublic = document.getElementById('grove-public').checked; - - try { - const response = await fetch(`${API_BASE}/groves`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, description, is_public: isPublic }) - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to create grove'); - } - - showToast('Grove created successfully!', 'success'); - closeModals(); - loadGroves(); - - // Reset form - document.getElementById('grove-name').value = ''; - document.getElementById('grove-description').value = ''; - document.getElementById('grove-public').checked = true; - } catch (error) { - showToast(error.message, 'error'); - } -} - -// Create Tree -function showCreateTreeModal() { - document.getElementById('create-tree-modal').classList.remove('hidden'); - document.getElementById('tree-name').focus(); -} - -async function createTree(e) { - e.preventDefault(); - - const name = document.getElementById('tree-name').value; - const description = document.getElementById('tree-description').value; - - try { - const response = await fetch(`${API_BASE}/grove/${currentGrove}/trees`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, description }) - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to create tree'); - } - - showToast('Tree created successfully!', 'success'); - closeModals(); - loadTrees(currentGrove); - - // Reset form - document.getElementById('tree-name').value = ''; - document.getElementById('tree-description').value = ''; - } catch (error) { - showToast(error.message, 'error'); - } -} - -// Upload -async function loadGrovesForUpload() { - const select = document.getElementById('upload-grove'); - select.innerHTML = ''; - - try { - const response = await fetch(`${API_BASE}/groves`); - const groves = await response.json(); - - select.innerHTML = '' + - (groves || []).map(g => ``).join(''); - } catch (error) { - select.innerHTML = ''; - } -} - -async function loadTreesForUpload() { - const groveName = document.getElementById('upload-grove').value; - const select = document.getElementById('upload-tree'); - - if (!groveName) { - select.innerHTML = ''; - return; - } - - select.innerHTML = ''; - - try { - const response = await fetch(`${API_BASE}/grove/${groveName}/trees`); - const trees = await response.json(); - - select.innerHTML = '' + - (trees || []).map(t => ``).join(''); - } catch (error) { - select.innerHTML = ''; - } -} - -function updateFileName() { - const input = document.getElementById('upload-file'); - const display = document.getElementById('file-name'); - display.textContent = input.files[0]?.name || ''; -} - -async function uploadArtifact(e) { - e.preventDefault(); - - const grove = document.getElementById('upload-grove').value; - const tree = document.getElementById('upload-tree').value; - const file = document.getElementById('upload-file').files[0]; - const tag = document.getElementById('upload-tag').value; - - const formData = new FormData(); - formData.append('file', file); - if (tag) formData.append('tag', tag); - - const resultDiv = document.getElementById('upload-result'); - resultDiv.innerHTML = '
Uploading...
'; - resultDiv.classList.remove('hidden', 'success', 'error'); - - try { - const response = await fetch(`${API_BASE}/grove/${grove}/${tree}/cultivate`, { - method: 'POST', - body: formData - }); - - const result = await response.json(); - - if (!response.ok) { - throw new Error(result.error || 'Upload failed'); - } - - resultDiv.classList.add('success'); - resultDiv.innerHTML = ` -

Upload Successful!

-
-
Fruit ID
-
${result.fruit_id}
-
Size
-
${formatBytes(result.size)}
-
Grove
-
${escapeHtml(result.grove)}
-
Tree
-
${escapeHtml(result.tree)}
- ${result.tag ? `
Tag
${escapeHtml(result.tag)}
` : ''} -
- - `; - - showToast('Artifact uploaded successfully!', 'success'); - } catch (error) { - resultDiv.classList.add('error'); - resultDiv.innerHTML = `

Upload Failed

${escapeHtml(error.message)}

`; - showToast(error.message, 'error'); - } -} - -async function uploadToTree(e) { - e.preventDefault(); - - const file = document.getElementById('tree-upload-file').files[0]; - const tag = document.getElementById('tree-upload-tag').value; - - const formData = new FormData(); - formData.append('file', file); - if (tag) formData.append('tag', tag); - - try { - const response = await fetch(`${API_BASE}/grove/${currentGrove}/${currentTree}/cultivate`, { - method: 'POST', - body: formData - }); - - const result = await response.json(); - - if (!response.ok) { - throw new Error(result.error || 'Upload failed'); - } - - showToast('Artifact uploaded successfully!', 'success'); - - // Reload grafts - loadGrafts(currentGrove, currentTree); - - // Reset form - document.getElementById('tree-upload-file').value = ''; - document.getElementById('tree-upload-tag').value = ''; - } catch (error) { - showToast(error.message, 'error'); - } -} - -// Search -function handleSearchKeyup(e) { - if (e.key === 'Enter') { - searchFruit(); - } -} - -async function searchFruit() { - const fruitId = document.getElementById('search-input').value.trim(); - const resultDiv = document.getElementById('search-result'); - - if (!fruitId) { - showToast('Please enter a fruit ID', 'error'); - return; - } - - resultDiv.innerHTML = '
Searching...
'; - resultDiv.classList.remove('hidden', 'success', 'error'); - - try { - const response = await fetch(`${API_BASE}/fruit/${fruitId}`); - const result = await response.json(); - - if (!response.ok) { - throw new Error(result.error || 'Fruit not found'); - } - - resultDiv.classList.add('success'); - resultDiv.innerHTML = ` -

Fruit Found

-
-
Fruit ID
-
${result.id}
-
Original Name
-
${escapeHtml(result.original_name || 'Unknown')}
-
Size
-
${formatBytes(result.size)}
-
Content Type
-
${escapeHtml(result.content_type || 'Unknown')}
-
Created
-
${formatDate(result.created_at)}
-
Reference Count
-
${result.ref_count}
-
- `; - } catch (error) { - resultDiv.classList.add('error'); - resultDiv.innerHTML = `

Not Found

${escapeHtml(error.message)}

`; - } -} - -// Modals -function closeModals() { - document.querySelectorAll('.modal').forEach(m => m.classList.add('hidden')); -} - -// Close modal on outside click -document.addEventListener('click', (e) => { - if (e.target.classList.contains('modal')) { - closeModals(); - } -}); - -// Close modal on Escape -document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - closeModals(); - } -}); - -// Toast notifications -function showToast(message, type = 'info') { - const container = document.getElementById('toast-container'); - const toast = document.createElement('div'); - toast.className = `toast ${type}`; - toast.textContent = message; - container.appendChild(toast); - - setTimeout(() => { - toast.remove(); - }, 3000); -} - -// Utilities -function escapeHtml(str) { - if (!str) return ''; - const div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; -} - -function formatDate(dateStr) { - if (!dateStr) return 'Unknown'; - const date = new Date(dateStr); - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); -} - -function formatBytes(bytes) { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; -} - -function copyToClipboard(text) { - navigator.clipboard.writeText(text).then(() => { - showToast('Copied to clipboard!', 'success'); - }); -} diff --git a/internal/api/static/index.html b/internal/api/static/index.html deleted file mode 100644 index 6c3ab5b..0000000 --- a/internal/api/static/index.html +++ /dev/null @@ -1,183 +0,0 @@ - - - - - - Orchard - Content Addressable Storage - - - - - -
- -
-
-

Groves

- -
-
-
Loading groves...
-
-
- - -
-
- -

Grove

-
-
-
-
-

Trees

- -
-
-
-
- - -
-
- -

Tree

-
-
-
-
-

Versions (Grafts)

-
-
-
-
-

Upload Artifact

-
-
- - -
-
- - -
- -
-
-
- - -
-

Upload Artifact

-
-
-
- - -
-
- - -
-
-
- -
- -

Drop file here or click to browse

- -
-
-
- - -
- -
- -
- - -
-

Search Artifacts

- - -
-
- - - - - - - - -
- - - - diff --git a/internal/api/static/style.css b/internal/api/static/style.css deleted file mode 100644 index 21889de..0000000 --- a/internal/api/static/style.css +++ /dev/null @@ -1,603 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -:root { - --primary: #059669; - --primary-dark: #047857; - --primary-light: #10b981; - --secondary: #6b7280; - --background: #f3f4f6; - --surface: #ffffff; - --text: #1f2937; - --text-light: #6b7280; - --border: #e5e7eb; - --success: #10b981; - --error: #ef4444; - --warning: #f59e0b; - --radius: 8px; - --shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06); - --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05); -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; - background: var(--background); - color: var(--text); - line-height: 1.6; -} - -/* Navbar */ -.navbar { - background: var(--surface); - border-bottom: 1px solid var(--border); - padding: 0 2rem; - height: 60px; - display: flex; - align-items: center; - justify-content: space-between; - position: sticky; - top: 0; - z-index: 100; -} - -.nav-brand { - font-size: 1.5rem; - font-weight: 700; - color: var(--primary); - display: flex; - align-items: center; - gap: 0.5rem; -} - -.logo { - font-size: 1.75rem; -} - -.nav-links { - display: flex; - gap: 0.5rem; -} - -.nav-link { - padding: 0.5rem 1rem; - text-decoration: none; - color: var(--text-light); - border-radius: var(--radius); - transition: all 0.2s; -} - -.nav-link:hover { - background: var(--background); - color: var(--text); -} - -.nav-link.active { - background: var(--primary); - color: white; -} - -/* Container */ -.container { - max-width: 1200px; - margin: 0 auto; - padding: 2rem; -} - -/* Views */ -.view { - display: none; -} - -.view.active { - display: block; -} - -.view-header { - display: flex; - align-items: center; - gap: 1rem; - margin-bottom: 1.5rem; -} - -.view-header h1 { - flex: 1; -} - -/* Cards */ -.card { - background: var(--surface); - border-radius: var(--radius); - border: 1px solid var(--border); - padding: 1.5rem; - box-shadow: var(--shadow); -} - -.card-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 1rem; -} - -.grove-card, .tree-card { - background: var(--surface); - border-radius: var(--radius); - border: 1px solid var(--border); - padding: 1.25rem; - cursor: pointer; - transition: all 0.2s; -} - -.grove-card:hover, .tree-card:hover { - border-color: var(--primary); - box-shadow: var(--shadow-lg); - transform: translateY(-2px); -} - -.grove-card h3, .tree-card h3 { - color: var(--primary); - margin-bottom: 0.5rem; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.grove-card p, .tree-card p { - color: var(--text-light); - font-size: 0.9rem; -} - -.grove-card .meta, .tree-card .meta { - margin-top: 1rem; - padding-top: 1rem; - border-top: 1px solid var(--border); - font-size: 0.8rem; - color: var(--text-light); -} - -.badge { - display: inline-block; - padding: 0.2rem 0.5rem; - border-radius: 4px; - font-size: 0.75rem; - font-weight: 500; -} - -.badge-public { - background: #d1fae5; - color: #065f46; -} - -.badge-private { - background: #fef3c7; - color: #92400e; -} - -/* Sections */ -.section { - margin-top: 2rem; -} - -.section-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 1rem; -} - -.section-header h2 { - font-size: 1.25rem; -} - -/* Buttons */ -.btn { - padding: 0.5rem 1rem; - border-radius: var(--radius); - border: none; - font-size: 0.9rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - display: inline-flex; - align-items: center; - gap: 0.5rem; -} - -.btn-primary { - background: var(--primary); - color: white; -} - -.btn-primary:hover { - background: var(--primary-dark); -} - -.btn-secondary { - background: var(--background); - color: var(--text); - border: 1px solid var(--border); -} - -.btn-secondary:hover { - background: var(--border); -} - -.btn-lg { - padding: 0.75rem 1.5rem; - font-size: 1rem; -} - -/* Forms */ -.form-group { - margin-bottom: 1rem; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; - font-weight: 500; - color: var(--text); -} - -.form-group input[type="text"], -.form-group input[type="email"], -.form-group textarea, -.form-group select { - width: 100%; - padding: 0.75rem; - border: 1px solid var(--border); - border-radius: var(--radius); - font-size: 1rem; - transition: border-color 0.2s; -} - -.form-group input:focus, -.form-group textarea:focus, -.form-group select:focus { - outline: none; - border-color: var(--primary); -} - -.form-group textarea { - min-height: 100px; - resize: vertical; -} - -.form-group.checkbox label { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; -} - -.form-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; -} - -/* File Drop */ -.file-drop { - border: 2px dashed var(--border); - border-radius: var(--radius); - padding: 2rem; - text-align: center; - cursor: pointer; - transition: all 0.2s; - position: relative; -} - -.file-drop:hover { - border-color: var(--primary); - background: #f0fdf4; -} - -.file-drop input[type="file"] { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0; - cursor: pointer; -} - -.file-drop p { - color: var(--text-light); - margin-bottom: 0.5rem; -} - -#file-name { - color: var(--primary); - font-weight: 500; -} - -/* Upload Form */ -.upload-form { - max-width: 600px; -} - -.upload-form.card { - padding: 2rem; -} - -/* Tables */ -.table-container { - overflow-x: auto; -} - -table { - width: 100%; - border-collapse: collapse; - background: var(--surface); - border-radius: var(--radius); - overflow: hidden; -} - -th, td { - padding: 0.75rem 1rem; - text-align: left; - border-bottom: 1px solid var(--border); -} - -th { - background: var(--background); - font-weight: 600; - color: var(--text-light); - font-size: 0.85rem; - text-transform: uppercase; -} - -tr:hover { - background: #f9fafb; -} - -.hash { - font-family: 'Monaco', 'Menlo', monospace; - font-size: 0.85rem; - color: var(--text-light); -} - -.hash-short { - cursor: pointer; -} - -.hash-short:hover { - color: var(--primary); -} - -/* Search */ -.search-box { - display: flex; - gap: 1rem; - max-width: 600px; - margin-bottom: 2rem; -} - -.search-box input { - flex: 1; - padding: 0.75rem 1rem; - border: 1px solid var(--border); - border-radius: var(--radius); - font-size: 1rem; -} - -.search-box input:focus { - outline: none; - border-color: var(--primary); -} - -/* Result Card */ -.result-card { - background: var(--surface); - border-radius: var(--radius); - border: 1px solid var(--border); - padding: 1.5rem; - margin-top: 1.5rem; - max-width: 600px; -} - -.result-card.success { - border-color: var(--success); - background: #f0fdf4; -} - -.result-card.error { - border-color: var(--error); - background: #fef2f2; -} - -.result-card h3 { - margin-bottom: 1rem; -} - -.result-card dl { - display: grid; - grid-template-columns: 150px 1fr; - gap: 0.5rem; -} - -.result-card dt { - font-weight: 500; - color: var(--text-light); -} - -.result-card dd { - word-break: break-all; -} - -.result-card .actions { - margin-top: 1rem; - padding-top: 1rem; - border-top: 1px solid var(--border); -} - -/* Modal */ -.modal { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0,0,0,0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.modal.hidden { - display: none; -} - -.modal-content { - background: var(--surface); - border-radius: var(--radius); - width: 100%; - max-width: 500px; - max-height: 90vh; - overflow-y: auto; -} - -.modal-header { - padding: 1rem 1.5rem; - border-bottom: 1px solid var(--border); - display: flex; - align-items: center; - justify-content: space-between; -} - -.modal-header h2 { - font-size: 1.25rem; -} - -.modal-close { - background: none; - border: none; - font-size: 1.5rem; - cursor: pointer; - color: var(--text-light); -} - -.modal-close:hover { - color: var(--text); -} - -.modal-content form { - padding: 1.5rem; -} - -.modal-actions { - display: flex; - justify-content: flex-end; - gap: 0.5rem; - margin-top: 1.5rem; -} - -/* Info boxes */ -.grove-info, .tree-info { - background: var(--surface); - border-radius: var(--radius); - border: 1px solid var(--border); - padding: 1rem 1.5rem; - margin-bottom: 1rem; -} - -.info-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; -} - -.info-item label { - display: block; - font-size: 0.8rem; - color: var(--text-light); - margin-bottom: 0.25rem; -} - -.info-item span { - font-weight: 500; -} - -/* Toast */ -#toast-container { - position: fixed; - bottom: 2rem; - right: 2rem; - z-index: 2000; -} - -.toast { - background: var(--text); - color: white; - padding: 1rem 1.5rem; - border-radius: var(--radius); - margin-top: 0.5rem; - box-shadow: var(--shadow-lg); - animation: slideIn 0.3s ease; -} - -.toast.success { - background: var(--success); -} - -.toast.error { - background: var(--error); -} - -@keyframes slideIn { - from { - transform: translateX(100%); - opacity: 0; - } - to { - transform: translateX(0); - opacity: 1; - } -} - -/* Loading */ -.loading { - text-align: center; - padding: 2rem; - color: var(--text-light); -} - -/* Hidden */ -.hidden { - display: none !important; -} - -/* Empty State */ -.empty-state { - text-align: center; - padding: 3rem; - color: var(--text-light); -} - -.empty-state h3 { - margin-bottom: 0.5rem; - color: var(--text); -} - -/* Responsive */ -@media (max-width: 768px) { - .navbar { - padding: 0 1rem; - } - - .container { - padding: 1rem; - } - - .form-row { - grid-template-columns: 1fr; - } - - .card-grid { - grid-template-columns: 1fr; - } -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 83cd0d0..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,104 +0,0 @@ -package config - -import ( - "strings" - - "github.com/spf13/viper" -) - -type Config struct { - Server ServerConfig `mapstructure:"server"` - Database DatabaseConfig `mapstructure:"database"` - S3 S3Config `mapstructure:"s3"` - Redis RedisConfig `mapstructure:"redis"` -} - -type ServerConfig struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` -} - -type DatabaseConfig struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - User string `mapstructure:"user"` - Password string `mapstructure:"password"` - DBName string `mapstructure:"dbname"` - SSLMode string `mapstructure:"sslmode"` -} - -type S3Config struct { - Endpoint string `mapstructure:"endpoint"` - Region string `mapstructure:"region"` - Bucket string `mapstructure:"bucket"` - AccessKeyID string `mapstructure:"access_key_id"` - SecretAccessKey string `mapstructure:"secret_access_key"` - UsePathStyle bool `mapstructure:"use_path_style"` -} - -type RedisConfig struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - Password string `mapstructure:"password"` - DB int `mapstructure:"db"` -} - -func Load() (*Config, error) { - viper.SetConfigName("config") - viper.SetConfigType("yaml") - viper.AddConfigPath(".") - viper.AddConfigPath("/etc/orchard") - - // Environment variable overrides - viper.SetEnvPrefix("ORCHARD") - viper.AutomaticEnv() - viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - - // Bind environment variables explicitly for nested config - viper.BindEnv("server.host", "ORCHARD_SERVER_HOST") - viper.BindEnv("server.port", "ORCHARD_SERVER_PORT") - viper.BindEnv("database.host", "ORCHARD_DATABASE_HOST") - viper.BindEnv("database.port", "ORCHARD_DATABASE_PORT") - viper.BindEnv("database.user", "ORCHARD_DATABASE_USER") - viper.BindEnv("database.password", "ORCHARD_DATABASE_PASSWORD") - viper.BindEnv("database.dbname", "ORCHARD_DATABASE_DBNAME") - viper.BindEnv("database.sslmode", "ORCHARD_DATABASE_SSLMODE") - viper.BindEnv("s3.endpoint", "ORCHARD_S3_ENDPOINT") - viper.BindEnv("s3.region", "ORCHARD_S3_REGION") - viper.BindEnv("s3.bucket", "ORCHARD_S3_BUCKET") - viper.BindEnv("s3.access_key_id", "ORCHARD_S3_ACCESS_KEY_ID") - viper.BindEnv("s3.secret_access_key", "ORCHARD_S3_SECRET_ACCESS_KEY") - viper.BindEnv("s3.use_path_style", "ORCHARD_S3_USE_PATH_STYLE") - viper.BindEnv("redis.host", "ORCHARD_REDIS_HOST") - viper.BindEnv("redis.port", "ORCHARD_REDIS_PORT") - viper.BindEnv("redis.password", "ORCHARD_REDIS_PASSWORD") - viper.BindEnv("redis.db", "ORCHARD_REDIS_DB") - - // Defaults - viper.SetDefault("server.host", "0.0.0.0") - viper.SetDefault("server.port", 8080) - viper.SetDefault("database.host", "localhost") - viper.SetDefault("database.port", 5432) - viper.SetDefault("database.user", "orchard") - viper.SetDefault("database.dbname", "orchard") - viper.SetDefault("database.sslmode", "disable") - viper.SetDefault("s3.region", "us-east-1") - viper.SetDefault("s3.bucket", "orchard-artifacts") - viper.SetDefault("s3.use_path_style", true) - viper.SetDefault("redis.host", "localhost") - viper.SetDefault("redis.port", 6379) - viper.SetDefault("redis.db", 0) - - if err := viper.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { - return nil, err - } - } - - var cfg Config - if err := viper.Unmarshal(&cfg); err != nil { - return nil, err - } - - return &cfg, nil -} diff --git a/internal/models/models.go b/internal/models/models.go deleted file mode 100644 index 4305762..0000000 --- a/internal/models/models.go +++ /dev/null @@ -1,110 +0,0 @@ -package models - -import ( - "time" -) - -// Fruit represents a content-addressable artifact identified by SHA256 hash -type Fruit struct { - ID string `json:"id" db:"id"` // SHA256 hash - Size int64 `json:"size" db:"size"` // Size in bytes - ContentType string `json:"content_type" db:"content_type"` // MIME type - OriginalName string `json:"original_name" db:"original_name"` // Original filename - CreatedAt time.Time `json:"created_at" db:"created_at"` - CreatedBy string `json:"created_by" db:"created_by"` // Harvester ID - RefCount int `json:"ref_count" db:"ref_count"` // Reference count - S3Key string `json:"-" db:"s3_key"` // S3 object key -} - -// Tree represents a named package within a grove -type Tree struct { - ID string `json:"id" db:"id"` - GroveID string `json:"grove_id" db:"grove_id"` - Name string `json:"name" db:"name"` - Description string `json:"description" db:"description"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// Grove represents a top-level project -type Grove struct { - ID string `json:"id" db:"id"` - Name string `json:"name" db:"name"` - Description string `json:"description" db:"description"` - IsPublic bool `json:"is_public" db:"is_public"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - CreatedBy string `json:"created_by" db:"created_by"` -} - -// Graft represents an alias/tag pointing to a specific fruit -type Graft struct { - ID string `json:"id" db:"id"` - TreeID string `json:"tree_id" db:"tree_id"` - Name string `json:"name" db:"name"` // e.g., "latest", "v1.2.3", "stable" - FruitID string `json:"fruit_id" db:"fruit_id"` // SHA256 of the fruit - CreatedAt time.Time `json:"created_at" db:"created_at"` - CreatedBy string `json:"created_by" db:"created_by"` -} - -// GraftHistory tracks changes to grafts for rollback capability -type GraftHistory struct { - ID string `json:"id" db:"id"` - GraftID string `json:"graft_id" db:"graft_id"` - OldFruitID string `json:"old_fruit_id" db:"old_fruit_id"` - NewFruitID string `json:"new_fruit_id" db:"new_fruit_id"` - ChangedAt time.Time `json:"changed_at" db:"changed_at"` - ChangedBy string `json:"changed_by" db:"changed_by"` -} - -// Harvest represents metadata about an upload event -type Harvest struct { - ID string `json:"id" db:"id"` - FruitID string `json:"fruit_id" db:"fruit_id"` - TreeID string `json:"tree_id" db:"tree_id"` - OriginalName string `json:"original_name" db:"original_name"` - HarvestedAt time.Time `json:"harvested_at" db:"harvested_at"` - HarvestedBy string `json:"harvested_by" db:"harvested_by"` - SourceIP string `json:"source_ip" db:"source_ip"` -} - -// Consumer tracks which projects consume specific packages -type Consumer struct { - ID string `json:"id" db:"id"` - TreeID string `json:"tree_id" db:"tree_id"` - ProjectURL string `json:"project_url" db:"project_url"` - LastAccess time.Time `json:"last_access" db:"last_access"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// AccessPermission defines grove-level access control -type AccessPermission struct { - ID string `json:"id" db:"id"` - GroveID string `json:"grove_id" db:"grove_id"` - UserID string `json:"user_id" db:"user_id"` - Level string `json:"level" db:"level"` // read, write, admin - CreatedAt time.Time `json:"created_at" db:"created_at"` - ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"` -} - -// AuditLog tracks all operations for compliance -type AuditLog struct { - ID string `json:"id" db:"id"` - Action string `json:"action" db:"action"` - Resource string `json:"resource" db:"resource"` - UserID string `json:"user_id" db:"user_id"` - Details string `json:"details" db:"details"` // JSON blob - Timestamp time.Time `json:"timestamp" db:"timestamp"` - SourceIP string `json:"source_ip" db:"source_ip"` -} - -// APIKey for programmatic access -type APIKey struct { - ID string `json:"id" db:"id"` - KeyHash string `json:"-" db:"key_hash"` // Hashed API key - Name string `json:"name" db:"name"` - UserID string `json:"user_id" db:"user_id"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"` - LastUsed *time.Time `json:"last_used,omitempty" db:"last_used"` -} diff --git a/internal/storage/database.go b/internal/storage/database.go deleted file mode 100644 index 88d140d..0000000 --- a/internal/storage/database.go +++ /dev/null @@ -1,363 +0,0 @@ -package storage - -import ( - "context" - "database/sql" - _ "embed" - "fmt" - "time" - - "gitlab.global.bsf.tools/esv/bsf/bsf-integration/orchard/orchard-mvp/internal/config" - "gitlab.global.bsf.tools/esv/bsf/bsf-integration/orchard/orchard-mvp/internal/models" - "github.com/google/uuid" - _ "github.com/lib/pq" -) - -//go:embed migrations/001_initial.sql -var migrationSQL string - -// Database handles all database operations -type Database struct { - db *sql.DB -} - -// NewDatabase creates a new database connection and runs migrations -func NewDatabase(cfg *config.DatabaseConfig) (*Database, error) { - dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", - cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode) - - db, err := sql.Open("postgres", dsn) - if err != nil { - return nil, fmt.Errorf("failed to connect to database: %w", err) - } - - if err := db.Ping(); err != nil { - return nil, fmt.Errorf("failed to ping database: %w", err) - } - - d := &Database{db: db} - - // Run migrations - if err := d.runMigrations(); err != nil { - return nil, fmt.Errorf("failed to run migrations: %w", err) - } - - return d, nil -} - -// runMigrations executes the embedded SQL migrations -func (d *Database) runMigrations() error { - _, err := d.db.Exec(migrationSQL) - return err -} - -// Close closes the database connection -func (d *Database) Close() error { - return d.db.Close() -} - -// Grove operations - -func (d *Database) CreateGrove(ctx context.Context, grove *models.Grove) error { - grove.ID = uuid.New().String() - grove.CreatedAt = time.Now() - grove.UpdatedAt = grove.CreatedAt - - _, err := d.db.ExecContext(ctx, ` - INSERT INTO groves (id, name, description, is_public, created_at, updated_at, created_by) - VALUES ($1, $2, $3, $4, $5, $6, $7) - `, grove.ID, grove.Name, grove.Description, grove.IsPublic, grove.CreatedAt, grove.UpdatedAt, grove.CreatedBy) - - return err -} - -func (d *Database) GetGrove(ctx context.Context, name string) (*models.Grove, error) { - var grove models.Grove - err := d.db.QueryRowContext(ctx, ` - SELECT id, name, description, is_public, created_at, updated_at, created_by - FROM groves WHERE name = $1 - `, name).Scan(&grove.ID, &grove.Name, &grove.Description, &grove.IsPublic, - &grove.CreatedAt, &grove.UpdatedAt, &grove.CreatedBy) - - if err == sql.ErrNoRows { - return nil, nil - } - return &grove, err -} - -func (d *Database) ListGroves(ctx context.Context, userID string) ([]*models.Grove, error) { - rows, err := d.db.QueryContext(ctx, ` - SELECT g.id, g.name, g.description, g.is_public, g.created_at, g.updated_at, g.created_by - FROM groves g - LEFT JOIN access_permissions ap ON g.id = ap.grove_id AND ap.user_id = $1 - WHERE g.is_public = true OR ap.user_id IS NOT NULL - ORDER BY g.name - `, userID) - if err != nil { - return nil, err - } - defer rows.Close() - - var groves []*models.Grove - for rows.Next() { - var grove models.Grove - if err := rows.Scan(&grove.ID, &grove.Name, &grove.Description, &grove.IsPublic, - &grove.CreatedAt, &grove.UpdatedAt, &grove.CreatedBy); err != nil { - return nil, err - } - groves = append(groves, &grove) - } - return groves, nil -} - -// Tree operations - -func (d *Database) CreateTree(ctx context.Context, tree *models.Tree) error { - tree.ID = uuid.New().String() - tree.CreatedAt = time.Now() - tree.UpdatedAt = tree.CreatedAt - - _, err := d.db.ExecContext(ctx, ` - INSERT INTO trees (id, grove_id, name, description, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6) - `, tree.ID, tree.GroveID, tree.Name, tree.Description, tree.CreatedAt, tree.UpdatedAt) - - return err -} - -func (d *Database) GetTree(ctx context.Context, groveID, name string) (*models.Tree, error) { - var tree models.Tree - err := d.db.QueryRowContext(ctx, ` - SELECT id, grove_id, name, description, created_at, updated_at - FROM trees WHERE grove_id = $1 AND name = $2 - `, groveID, name).Scan(&tree.ID, &tree.GroveID, &tree.Name, &tree.Description, - &tree.CreatedAt, &tree.UpdatedAt) - - if err == sql.ErrNoRows { - return nil, nil - } - return &tree, err -} - -func (d *Database) ListTrees(ctx context.Context, groveID string) ([]*models.Tree, error) { - rows, err := d.db.QueryContext(ctx, ` - SELECT id, grove_id, name, description, created_at, updated_at - FROM trees WHERE grove_id = $1 - ORDER BY name - `, groveID) - if err != nil { - return nil, err - } - defer rows.Close() - - var trees []*models.Tree - for rows.Next() { - var tree models.Tree - if err := rows.Scan(&tree.ID, &tree.GroveID, &tree.Name, &tree.Description, - &tree.CreatedAt, &tree.UpdatedAt); err != nil { - return nil, err - } - trees = append(trees, &tree) - } - return trees, nil -} - -// Fruit operations - -func (d *Database) CreateFruit(ctx context.Context, fruit *models.Fruit) error { - fruit.CreatedAt = time.Now() - fruit.RefCount = 1 - - _, err := d.db.ExecContext(ctx, ` - INSERT INTO fruits (id, size, content_type, original_name, created_at, created_by, ref_count, s3_key) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (id) DO UPDATE SET ref_count = fruits.ref_count + 1 - `, fruit.ID, fruit.Size, fruit.ContentType, fruit.OriginalName, - fruit.CreatedAt, fruit.CreatedBy, fruit.RefCount, fruit.S3Key) - - return err -} - -func (d *Database) GetFruit(ctx context.Context, id string) (*models.Fruit, error) { - var fruit models.Fruit - err := d.db.QueryRowContext(ctx, ` - SELECT id, size, content_type, original_name, created_at, created_by, ref_count, s3_key - FROM fruits WHERE id = $1 - `, id).Scan(&fruit.ID, &fruit.Size, &fruit.ContentType, &fruit.OriginalName, - &fruit.CreatedAt, &fruit.CreatedBy, &fruit.RefCount, &fruit.S3Key) - - if err == sql.ErrNoRows { - return nil, nil - } - return &fruit, err -} - -// Graft operations - -func (d *Database) CreateGraft(ctx context.Context, graft *models.Graft) error { - graft.ID = uuid.New().String() - graft.CreatedAt = time.Now() - - _, err := d.db.ExecContext(ctx, ` - INSERT INTO grafts (id, tree_id, name, fruit_id, created_at, created_by) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (tree_id, name) DO UPDATE SET fruit_id = $4, created_at = $5, created_by = $6 - `, graft.ID, graft.TreeID, graft.Name, graft.FruitID, graft.CreatedAt, graft.CreatedBy) - - return err -} - -func (d *Database) GetGraft(ctx context.Context, treeID, name string) (*models.Graft, error) { - var graft models.Graft - err := d.db.QueryRowContext(ctx, ` - SELECT id, tree_id, name, fruit_id, created_at, created_by - FROM grafts WHERE tree_id = $1 AND name = $2 - `, treeID, name).Scan(&graft.ID, &graft.TreeID, &graft.Name, &graft.FruitID, - &graft.CreatedAt, &graft.CreatedBy) - - if err == sql.ErrNoRows { - return nil, nil - } - return &graft, err -} - -func (d *Database) ListGrafts(ctx context.Context, treeID string) ([]*models.Graft, error) { - rows, err := d.db.QueryContext(ctx, ` - SELECT id, tree_id, name, fruit_id, created_at, created_by - FROM grafts WHERE tree_id = $1 - ORDER BY name - `, treeID) - if err != nil { - return nil, err - } - defer rows.Close() - - var grafts []*models.Graft - for rows.Next() { - var graft models.Graft - if err := rows.Scan(&graft.ID, &graft.TreeID, &graft.Name, &graft.FruitID, - &graft.CreatedAt, &graft.CreatedBy); err != nil { - return nil, err - } - grafts = append(grafts, &graft) - } - return grafts, nil -} - -// Harvest operations - -func (d *Database) CreateHarvest(ctx context.Context, harvest *models.Harvest) error { - harvest.ID = uuid.New().String() - harvest.HarvestedAt = time.Now() - - _, err := d.db.ExecContext(ctx, ` - INSERT INTO harvests (id, fruit_id, tree_id, original_name, harvested_at, harvested_by, source_ip) - VALUES ($1, $2, $3, $4, $5, $6, $7) - `, harvest.ID, harvest.FruitID, harvest.TreeID, harvest.OriginalName, - harvest.HarvestedAt, harvest.HarvestedBy, harvest.SourceIP) - - return err -} - -// Audit operations - -func (d *Database) CreateAuditLog(ctx context.Context, log *models.AuditLog) error { - log.ID = uuid.New().String() - log.Timestamp = time.Now() - - _, err := d.db.ExecContext(ctx, ` - INSERT INTO audit_logs (id, action, resource, user_id, details, timestamp, source_ip) - VALUES ($1, $2, $3, $4, $5, $6, $7) - `, log.ID, log.Action, log.Resource, log.UserID, log.Details, log.Timestamp, log.SourceIP) - - return err -} - -// Access control operations - -func (d *Database) CheckAccess(ctx context.Context, groveID, userID, requiredLevel string) (bool, error) { - // Check if grove is public (read access for everyone) - var isPublic bool - err := d.db.QueryRowContext(ctx, `SELECT is_public FROM groves WHERE id = $1`, groveID).Scan(&isPublic) - if err != nil { - return false, err - } - - if isPublic && requiredLevel == "read" { - return true, nil - } - - // Check user-specific permissions - var level string - err = d.db.QueryRowContext(ctx, ` - SELECT level FROM access_permissions - WHERE grove_id = $1 AND user_id = $2 AND (expires_at IS NULL OR expires_at > NOW()) - `, groveID, userID).Scan(&level) - - if err == sql.ErrNoRows { - return false, nil - } - if err != nil { - return false, err - } - - // Check permission hierarchy: admin > write > read - switch requiredLevel { - case "read": - return true, nil - case "write": - return level == "write" || level == "admin", nil - case "admin": - return level == "admin", nil - } - - return false, nil -} - -func (d *Database) GrantAccess(ctx context.Context, perm *models.AccessPermission) error { - perm.ID = uuid.New().String() - perm.CreatedAt = time.Now() - - _, err := d.db.ExecContext(ctx, ` - INSERT INTO access_permissions (id, grove_id, user_id, level, created_at, expires_at) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (grove_id, user_id) DO UPDATE SET level = $4, expires_at = $6 - `, perm.ID, perm.GroveID, perm.UserID, perm.Level, perm.CreatedAt, perm.ExpiresAt) - - return err -} - -// Consumer tracking - -func (d *Database) TrackConsumer(ctx context.Context, treeID, projectURL string) error { - _, err := d.db.ExecContext(ctx, ` - INSERT INTO consumers (id, tree_id, project_url, last_access, created_at) - VALUES ($1, $2, $3, NOW(), NOW()) - ON CONFLICT (tree_id, project_url) DO UPDATE SET last_access = NOW() - `, uuid.New().String(), treeID, projectURL) - - return err -} - -func (d *Database) GetConsumers(ctx context.Context, treeID string) ([]*models.Consumer, error) { - rows, err := d.db.QueryContext(ctx, ` - SELECT id, tree_id, project_url, last_access, created_at - FROM consumers WHERE tree_id = $1 - ORDER BY last_access DESC - `, treeID) - if err != nil { - return nil, err - } - defer rows.Close() - - var consumers []*models.Consumer - for rows.Next() { - var consumer models.Consumer - if err := rows.Scan(&consumer.ID, &consumer.TreeID, &consumer.ProjectURL, - &consumer.LastAccess, &consumer.CreatedAt); err != nil { - return nil, err - } - consumers = append(consumers, &consumer) - } - return consumers, nil -} diff --git a/internal/storage/migrations/001_initial.sql b/internal/storage/migrations/001_initial.sql deleted file mode 100644 index 1505a4f..0000000 --- a/internal/storage/migrations/001_initial.sql +++ /dev/null @@ -1,160 +0,0 @@ --- Orchard Database Schema --- Content-Addressable Storage System - --- Groves (Projects) -CREATE TABLE IF NOT EXISTS groves ( - id UUID PRIMARY KEY, - name VARCHAR(255) UNIQUE NOT NULL, - description TEXT, - is_public BOOLEAN DEFAULT true, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - created_by VARCHAR(255) NOT NULL -); - -CREATE INDEX idx_groves_name ON groves(name); -CREATE INDEX idx_groves_created_by ON groves(created_by); - --- Trees (Packages) -CREATE TABLE IF NOT EXISTS trees ( - id UUID PRIMARY KEY, - grove_id UUID NOT NULL REFERENCES groves(id) ON DELETE CASCADE, - name VARCHAR(255) NOT NULL, - description TEXT, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - UNIQUE(grove_id, name) -); - -CREATE INDEX idx_trees_grove_id ON trees(grove_id); -CREATE INDEX idx_trees_name ON trees(name); - --- Fruits (Content-Addressable Artifacts) -CREATE TABLE IF NOT EXISTS fruits ( - id VARCHAR(64) PRIMARY KEY, -- SHA256 hash - size BIGINT NOT NULL, - content_type VARCHAR(255), - original_name VARCHAR(1024), - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - created_by VARCHAR(255) NOT NULL, - ref_count INTEGER DEFAULT 1, - s3_key VARCHAR(1024) NOT NULL -); - -CREATE INDEX idx_fruits_created_at ON fruits(created_at); -CREATE INDEX idx_fruits_created_by ON fruits(created_by); - --- Grafts (Aliases/Tags) -CREATE TABLE IF NOT EXISTS grafts ( - id UUID PRIMARY KEY, - tree_id UUID NOT NULL REFERENCES trees(id) ON DELETE CASCADE, - name VARCHAR(255) NOT NULL, - fruit_id VARCHAR(64) NOT NULL REFERENCES fruits(id), - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - created_by VARCHAR(255) NOT NULL, - UNIQUE(tree_id, name) -); - -CREATE INDEX idx_grafts_tree_id ON grafts(tree_id); -CREATE INDEX idx_grafts_fruit_id ON grafts(fruit_id); - --- Graft History (for rollback capability) -CREATE TABLE IF NOT EXISTS graft_history ( - id UUID PRIMARY KEY, - graft_id UUID NOT NULL REFERENCES grafts(id) ON DELETE CASCADE, - old_fruit_id VARCHAR(64) REFERENCES fruits(id), - new_fruit_id VARCHAR(64) NOT NULL REFERENCES fruits(id), - changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - changed_by VARCHAR(255) NOT NULL -); - -CREATE INDEX idx_graft_history_graft_id ON graft_history(graft_id); - --- Harvests (Upload events) -CREATE TABLE IF NOT EXISTS harvests ( - id UUID PRIMARY KEY, - fruit_id VARCHAR(64) NOT NULL REFERENCES fruits(id), - tree_id UUID NOT NULL REFERENCES trees(id), - original_name VARCHAR(1024), - harvested_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - harvested_by VARCHAR(255) NOT NULL, - source_ip VARCHAR(45) -); - -CREATE INDEX idx_harvests_fruit_id ON harvests(fruit_id); -CREATE INDEX idx_harvests_tree_id ON harvests(tree_id); -CREATE INDEX idx_harvests_harvested_at ON harvests(harvested_at); - --- Consumers (Dependency tracking) -CREATE TABLE IF NOT EXISTS consumers ( - id UUID PRIMARY KEY, - tree_id UUID NOT NULL REFERENCES trees(id) ON DELETE CASCADE, - project_url VARCHAR(2048) NOT NULL, - last_access TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - UNIQUE(tree_id, project_url) -); - -CREATE INDEX idx_consumers_tree_id ON consumers(tree_id); -CREATE INDEX idx_consumers_last_access ON consumers(last_access); - --- Access Permissions -CREATE TABLE IF NOT EXISTS access_permissions ( - id UUID PRIMARY KEY, - grove_id UUID NOT NULL REFERENCES groves(id) ON DELETE CASCADE, - user_id VARCHAR(255) NOT NULL, - level VARCHAR(20) NOT NULL CHECK (level IN ('read', 'write', 'admin')), - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - expires_at TIMESTAMP WITH TIME ZONE, - UNIQUE(grove_id, user_id) -); - -CREATE INDEX idx_access_permissions_grove_id ON access_permissions(grove_id); -CREATE INDEX idx_access_permissions_user_id ON access_permissions(user_id); - --- API Keys -CREATE TABLE IF NOT EXISTS api_keys ( - id UUID PRIMARY KEY, - key_hash VARCHAR(64) NOT NULL UNIQUE, - name VARCHAR(255) NOT NULL, - user_id VARCHAR(255) NOT NULL, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - expires_at TIMESTAMP WITH TIME ZONE, - last_used TIMESTAMP WITH TIME ZONE -); - -CREATE INDEX idx_api_keys_user_id ON api_keys(user_id); -CREATE INDEX idx_api_keys_key_hash ON api_keys(key_hash); - --- Audit Logs (Immutable) -CREATE TABLE IF NOT EXISTS audit_logs ( - id UUID PRIMARY KEY, - action VARCHAR(100) NOT NULL, - resource VARCHAR(1024) NOT NULL, - user_id VARCHAR(255) NOT NULL, - details JSONB, - timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - source_ip VARCHAR(45) -); - -CREATE INDEX idx_audit_logs_action ON audit_logs(action); -CREATE INDEX idx_audit_logs_resource ON audit_logs(resource); -CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id); -CREATE INDEX idx_audit_logs_timestamp ON audit_logs(timestamp); - --- Trigger to update graft history on changes -CREATE OR REPLACE FUNCTION track_graft_changes() -RETURNS TRIGGER AS $$ -BEGIN - IF TG_OP = 'UPDATE' AND OLD.fruit_id != NEW.fruit_id THEN - INSERT INTO graft_history (id, graft_id, old_fruit_id, new_fruit_id, changed_at, changed_by) - VALUES (gen_random_uuid(), NEW.id, OLD.fruit_id, NEW.fruit_id, NOW(), NEW.created_by); - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER graft_changes_trigger - AFTER UPDATE ON grafts - FOR EACH ROW - EXECUTE FUNCTION track_graft_changes(); diff --git a/internal/storage/s3.go b/internal/storage/s3.go deleted file mode 100644 index cc4037d..0000000 --- a/internal/storage/s3.go +++ /dev/null @@ -1,158 +0,0 @@ -package storage - -import ( - "bytes" - "context" - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/credentials" - "github.com/aws/aws-sdk-go-v2/service/s3" - orchardconfig "gitlab.global.bsf.tools/esv/bsf/bsf-integration/orchard/orchard-mvp/internal/config" -) - -// S3Storage implements content-addressable storage using S3 -type S3Storage struct { - client *s3.Client - bucket string -} - -// NewS3Storage creates a new S3 storage backend -func NewS3Storage(cfg *orchardconfig.S3Config) (*S3Storage, error) { - customResolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) { - if cfg.Endpoint != "" { - return aws.Endpoint{ - URL: cfg.Endpoint, - HostnameImmutable: true, - }, nil - } - return aws.Endpoint{}, &aws.EndpointNotFoundError{} - }) - - awsCfg, err := config.LoadDefaultConfig(context.Background(), - config.WithRegion(cfg.Region), - config.WithEndpointResolverWithOptions(customResolver), - config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( - cfg.AccessKeyID, - cfg.SecretAccessKey, - "", - )), - ) - if err != nil { - return nil, fmt.Errorf("failed to load AWS config: %w", err) - } - - client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { - o.UsePathStyle = cfg.UsePathStyle - }) - - return &S3Storage{ - client: client, - bucket: cfg.Bucket, - }, nil -} - -// hashToKey converts a SHA256 hash to an S3 object key -// Uses directory structure: ab/cd/abcdef123... -func hashToKey(hash string) string { - if len(hash) < 4 { - return hash - } - return fmt.Sprintf("%s/%s/%s", hash[:2], hash[2:4], hash) -} - -// Store uploads content to S3 and returns the SHA256 hash -func (s *S3Storage) Store(ctx context.Context, reader io.Reader) (string, int64, error) { - // Read all content into memory to compute hash and enable seeking - data, err := io.ReadAll(reader) - if err != nil { - return "", 0, fmt.Errorf("failed to read content: %w", err) - } - - size := int64(len(data)) - - // Compute SHA256 hash - hasher := sha256.New() - hasher.Write(data) - hash := hex.EncodeToString(hasher.Sum(nil)) - finalKey := hashToKey(hash) - - // Check if object already exists (deduplication) - _, err = s.client.HeadObject(ctx, &s3.HeadObjectInput{ - Bucket: aws.String(s.bucket), - Key: aws.String(finalKey), - }) - if err == nil { - // Object already exists, return existing hash (deduplication) - return hash, size, nil - } - - // Upload to final location - _, err = s.client.PutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(s.bucket), - Key: aws.String(finalKey), - Body: bytes.NewReader(data), - ContentLength: aws.Int64(size), - }) - if err != nil { - return "", 0, fmt.Errorf("failed to upload to S3: %w", err) - } - - return hash, size, nil -} - -// Retrieve downloads content by SHA256 hash -func (s *S3Storage) Retrieve(ctx context.Context, hash string) (io.ReadCloser, error) { - key := hashToKey(hash) - - result, err := s.client.GetObject(ctx, &s3.GetObjectInput{ - Bucket: aws.String(s.bucket), - Key: aws.String(key), - }) - if err != nil { - return nil, fmt.Errorf("failed to retrieve object: %w", err) - } - - return result.Body, nil -} - -// Exists checks if a fruit exists by hash -func (s *S3Storage) Exists(ctx context.Context, hash string) (bool, error) { - key := hashToKey(hash) - - _, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ - Bucket: aws.String(s.bucket), - Key: aws.String(key), - }) - if err != nil { - return false, nil - } - return true, nil -} - -// GetSize returns the size of a stored object -func (s *S3Storage) GetSize(ctx context.Context, hash string) (int64, error) { - key := hashToKey(hash) - - result, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ - Bucket: aws.String(s.bucket), - Key: aws.String(key), - }) - if err != nil { - return 0, fmt.Errorf("failed to get object metadata: %w", err) - } - - return *result.ContentLength, nil -} - -// generateTempID creates a unique temporary ID -func generateTempID() string { - b := make([]byte, 16) - rand.Read(b) - return hex.EncodeToString(b) -} diff --git a/orchard-server b/orchard-server deleted file mode 100755 index 748d750..0000000 Binary files a/orchard-server and /dev/null differ