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 (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+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 && (
+
+ )}
+
+ {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 && (
+
+ )}
+
+ {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}
}
+
+
+
+
Tags / Versions
+ {grafts.length === 0 ? (
+
+
No tags yet. Upload an artifact with a tag to create one!
+
+ ) : (
+
+
+
+
+ | Tag |
+ Fruit ID |
+ Created |
+ Actions |
+
+
+
+ {grafts.map((graft) => (
+
+ | {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 = `
-
-
-
- | Tag |
- Fruit ID |
- Created |
- Actions |
-
-
-
- ${grafts.map(graft => `
-
- | ${escapeHtml(graft.name)} |
-
-
- ${graft.fruit_id.substring(0, 16)}...
-
- |
- ${formatDate(graft.created_at)} |
-
-
- Download
-
- |
-
- `).join('')}
-
-
- `;
- } 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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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