mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-25 11:44:16 +00:00
remove distribution from hold, add vulnerability scanning in appview.
1. Removing distribution/distribution from the Hold Service (biggest change) The hold service previously used distribution's StorageDriver interface for all blob operations. This replaces it with direct AWS SDK v2 calls through ATCR's own pkg/s3.S3Service: - New S3Service methods: Stat(), PutBytes(), Move(), Delete(), WalkBlobs(), ListPrefix() added to pkg/s3/types.go - Pull zone fix: Presigned URLs are now generated against the real S3 endpoint, then the host is swapped to the CDN URL post-signing (previously the CDN URL was set as the endpoint, which broke SigV4 signatures) - All hold subsystems migrated: GC, OCI uploads, XRPC handlers, profile uploads, scan broadcaster, manifest posts — all now use *s3.S3Service instead of storagedriver.StorageDriver - Config simplified: Removed configuration.Storage type and buildStorageConfigFromFields(); replaced with a simple S3Params() method - Mock expanded: MockS3Client gains an in-memory object store + 5 new methods, replacing duplicate mockStorageDriver implementations in tests (~160 lines deleted from each test file) 2. Vulnerability Scan UI in AppView (new feature) Displays scan results from the hold's PDS on the repository page: - New lexicon: io/atcr/hold/scan.json with vulnReportBlob field for storing full Grype reports - Two new HTMX endpoints: /api/scan-result (badge) and /api/vuln-details (modal with CVE table) - New templates: vuln-badge.html (severity count chips) and vuln-details.html (full CVE table with NVD/GHSA links) - Repository page: Lazy-loads scan badges per manifest via HTMX - Tests: ~590 lines of test coverage for both handlers 3. S3 Diagnostic Tool New cmd/s3-test/main.go (418 lines) — tests S3 connectivity with both SDK v1 and v2, including presigned URL generation, pull zone host swapping, and verbose signing debug output. 4. Deployment Tooling - New syncServiceUnit() for comparing/updating systemd units on servers - Update command now syncs config keys (adds missing keys from template) and service units with daemon-reload 5. DB Migration 0011_fix_captain_successor_column.yaml — rebuilds hold_captain_records to add the successor column that was missed in a previous migration. 6. Documentation - APPVIEW-UI-FUTURE.md rewritten as a status-tracked feature inventory - DISTRIBUTION.md renamed to CREDENTIAL_HELPER.md - New REMOVING_DISTRIBUTION.md — 480-line analysis of fully removing distribution from the appview side 7. go.mod aws-sdk-go v1 moved from indirect to direct (needed by cmd/s3-test).
This commit is contained in:
+16
-42
@@ -12,7 +12,6 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/distribution/distribution/v3/configuration"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"atcr.io/pkg/config"
|
||||
@@ -64,7 +63,7 @@ type RegistrationConfig struct {
|
||||
Region string `yaml:"region" comment:"Deployment region, auto-detected from cloud metadata or S3 config."`
|
||||
}
|
||||
|
||||
// StorageConfig holds S3 storage credentials and the internal distribution config.
|
||||
// StorageConfig holds S3 storage credentials.
|
||||
type StorageConfig struct {
|
||||
// S3-compatible access key.
|
||||
AccessKey string `yaml:"access_key" comment:"S3-compatible access key (AWS, Storj, Minio, UpCloud)."`
|
||||
@@ -83,24 +82,24 @@ type StorageConfig struct {
|
||||
|
||||
// CDN pull zone URL for presigned download URLs.
|
||||
PullZone string `yaml:"pull_zone" comment:"CDN pull zone URL for downloads. When set, presigned GET/HEAD URLs use this host instead of the S3 endpoint. Uploads and API calls still use the S3 endpoint."`
|
||||
|
||||
// Internal distribution storage config, built from the above fields.
|
||||
distStorage configuration.Storage `yaml:"-"`
|
||||
}
|
||||
|
||||
// Type returns the storage driver type name (always "s3").
|
||||
func (s StorageConfig) Type() string {
|
||||
return "s3"
|
||||
}
|
||||
|
||||
// Parameters returns the distribution driver parameters.
|
||||
func (s StorageConfig) Parameters() configuration.Parameters {
|
||||
if s.distStorage != nil {
|
||||
if params, ok := s.distStorage["s3"]; ok {
|
||||
return params
|
||||
}
|
||||
// S3Params returns a params map suitable for s3.NewS3Service.
|
||||
func (s StorageConfig) S3Params() map[string]any {
|
||||
params := map[string]any{
|
||||
"accesskey": s.AccessKey,
|
||||
"secretkey": s.SecretKey,
|
||||
"region": s.Region,
|
||||
"bucket": s.Bucket,
|
||||
}
|
||||
return nil
|
||||
if s.Endpoint != "" {
|
||||
params["regionendpoint"] = s.Endpoint
|
||||
params["forcepathstyle"] = true
|
||||
}
|
||||
if s.PullZone != "" {
|
||||
params["pullzone"] = s.PullZone
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// ServerConfig defines server settings
|
||||
@@ -276,9 +275,6 @@ func LoadConfig(yamlPath string) (*Config, error) {
|
||||
// Store config path for subsystem config loading (e.g. billing)
|
||||
cfg.configPath = yamlPath
|
||||
|
||||
// Build distribution storage config from struct fields
|
||||
cfg.Storage.distStorage = buildStorageConfigFromFields(cfg.Storage)
|
||||
|
||||
// Detect region from cloud metadata or S3 config
|
||||
if meta, err := DetectCloudMetadata(context.Background()); err == nil && meta != nil {
|
||||
cfg.Registration.Region = meta.Region
|
||||
@@ -290,25 +286,3 @@ func LoadConfig(yamlPath string) (*Config, error) {
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// buildStorageConfigFromFields creates S3 storage configuration from StorageConfig fields.
|
||||
func buildStorageConfigFromFields(sc StorageConfig) configuration.Storage {
|
||||
params := make(map[string]any)
|
||||
|
||||
params["accesskey"] = sc.AccessKey
|
||||
params["secretkey"] = sc.SecretKey
|
||||
params["region"] = sc.Region
|
||||
params["bucket"] = sc.Bucket
|
||||
if sc.Endpoint != "" {
|
||||
params["regionendpoint"] = sc.Endpoint
|
||||
params["forcepathstyle"] = true
|
||||
}
|
||||
if sc.PullZone != "" {
|
||||
params["pullzone"] = sc.PullZone
|
||||
}
|
||||
|
||||
storageCfg := configuration.Storage{}
|
||||
storageCfg["s3"] = configuration.Parameters(params)
|
||||
|
||||
return storageCfg
|
||||
}
|
||||
|
||||
+4
-18
@@ -187,7 +187,7 @@ func TestLoadConfig_KeyPathDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStorageConfigFromFields_S3_Complete(t *testing.T) {
|
||||
func TestS3Params_Complete(t *testing.T) {
|
||||
sc := StorageConfig{
|
||||
AccessKey: "test-access-key",
|
||||
SecretKey: "test-secret-key",
|
||||
@@ -196,14 +196,7 @@ func TestBuildStorageConfigFromFields_S3_Complete(t *testing.T) {
|
||||
Endpoint: "https://s3.example.com",
|
||||
}
|
||||
|
||||
cfg := buildStorageConfigFromFields(sc)
|
||||
|
||||
s3Params, ok := cfg["s3"]
|
||||
if !ok {
|
||||
t.Fatal("Expected s3 storage config")
|
||||
}
|
||||
|
||||
params := map[string]any(s3Params)
|
||||
params := sc.S3Params()
|
||||
|
||||
if params["accesskey"] != "test-access-key" {
|
||||
t.Errorf("Expected accesskey=test-access-key, got %v", params["accesskey"])
|
||||
@@ -222,7 +215,7 @@ func TestBuildStorageConfigFromFields_S3_Complete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStorageConfigFromFields_S3_NoEndpoint(t *testing.T) {
|
||||
func TestS3Params_NoEndpoint(t *testing.T) {
|
||||
sc := StorageConfig{
|
||||
AccessKey: "test-key",
|
||||
SecretKey: "test-secret",
|
||||
@@ -231,14 +224,7 @@ func TestBuildStorageConfigFromFields_S3_NoEndpoint(t *testing.T) {
|
||||
Endpoint: "", // No custom endpoint
|
||||
}
|
||||
|
||||
cfg := buildStorageConfigFromFields(sc)
|
||||
|
||||
s3Params, ok := cfg["s3"]
|
||||
if !ok {
|
||||
t.Fatal("Expected s3 storage config")
|
||||
}
|
||||
|
||||
params := map[string]any(s3Params)
|
||||
params := sc.S3Params()
|
||||
|
||||
// Should have default region
|
||||
if params["region"] != "us-east-1" {
|
||||
|
||||
+15
-22
@@ -14,8 +14,8 @@ import (
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/hold/pds"
|
||||
"atcr.io/pkg/s3"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
|
||||
)
|
||||
|
||||
// maxPreviewItems caps per-category detail slices to prevent memory/HTML bloat
|
||||
@@ -64,7 +64,7 @@ type GCPreview struct {
|
||||
// GarbageCollector handles cleanup of orphaned blobs from storage
|
||||
type GarbageCollector struct {
|
||||
pds *pds.HoldPDS
|
||||
driver storagedriver.StorageDriver
|
||||
s3 *s3.S3Service
|
||||
cfg Config
|
||||
logger *slog.Logger
|
||||
|
||||
@@ -117,10 +117,10 @@ type analysisResult struct {
|
||||
}
|
||||
|
||||
// NewGarbageCollector creates a new GC instance
|
||||
func NewGarbageCollector(holdPDS *pds.HoldPDS, driver storagedriver.StorageDriver, cfg Config) *GarbageCollector {
|
||||
func NewGarbageCollector(holdPDS *pds.HoldPDS, s3svc *s3.S3Service, cfg Config) *GarbageCollector {
|
||||
return &GarbageCollector{
|
||||
pds: holdPDS,
|
||||
driver: driver,
|
||||
s3: s3svc,
|
||||
cfg: cfg,
|
||||
logger: slog.Default().With("component", "gc"),
|
||||
stopCh: make(chan struct{}),
|
||||
@@ -454,15 +454,12 @@ func (gc *GarbageCollector) scanOrphanedBlobDetails(ctx context.Context, referen
|
||||
totalBlobs := 0
|
||||
blobsPath := "/docker/registry/v2/blobs"
|
||||
|
||||
err := gc.driver.Walk(ctx, blobsPath, func(fi storagedriver.FileInfo) error {
|
||||
if fi.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(fi.Path(), "/data") {
|
||||
err := gc.s3.WalkBlobs(ctx, blobsPath, func(key string, size int64) error {
|
||||
if !strings.HasSuffix(key, "/data") {
|
||||
return nil
|
||||
}
|
||||
|
||||
digest := extractDigestFromPath(fi.Path())
|
||||
digest := extractDigestFromPath(key)
|
||||
if digest == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -473,7 +470,7 @@ func (gc *GarbageCollector) scanOrphanedBlobDetails(ctx context.Context, referen
|
||||
if len(orphaned) < maxPreviewItems {
|
||||
orphaned = append(orphaned, OrphanedBlobDetail{
|
||||
Digest: digest,
|
||||
Size: fi.Size(),
|
||||
Size: size,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -677,18 +674,14 @@ func (gc *GarbageCollector) deleteOrphanedRecords(ctx context.Context, orphanedR
|
||||
func (gc *GarbageCollector) deleteOrphanedBlobs(ctx context.Context, referenced map[string]bool, result *GCResult) error {
|
||||
blobsPath := "/docker/registry/v2/blobs"
|
||||
|
||||
err := gc.driver.Walk(ctx, blobsPath, func(fi storagedriver.FileInfo) error {
|
||||
if fi.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := gc.s3.WalkBlobs(ctx, blobsPath, func(key string, size int64) error {
|
||||
// Only process data files
|
||||
if !strings.HasSuffix(fi.Path(), "/data") {
|
||||
if !strings.HasSuffix(key, "/data") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract digest from path
|
||||
digest := extractDigestFromPath(fi.Path())
|
||||
digest := extractDigestFromPath(key)
|
||||
if digest == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -700,15 +693,15 @@ func (gc *GarbageCollector) deleteOrphanedBlobs(ctx context.Context, referenced
|
||||
|
||||
result.OrphanedBlobs++
|
||||
|
||||
if err := gc.driver.Delete(ctx, fi.Path()); err != nil {
|
||||
gc.logger.Error("Failed to delete blob", "path", fi.Path(), "error", err)
|
||||
if err := gc.s3.Delete(ctx, key); err != nil {
|
||||
gc.logger.Error("Failed to delete blob", "path", key, "error", err)
|
||||
return nil // Continue with other blobs
|
||||
}
|
||||
result.BlobsDeleted++
|
||||
result.BytesReclaimed += fi.Size()
|
||||
result.BytesReclaimed += size
|
||||
gc.logger.Debug("Deleted orphaned blob",
|
||||
"digest", digest,
|
||||
"size", fi.Size())
|
||||
"size", size)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -256,7 +256,7 @@ func (h *XRPCHandler) CompleteMultipartUploadWithManager(ctx context.Context, up
|
||||
"source", sourcePath,
|
||||
"dest", destPath)
|
||||
|
||||
if _, err := h.driver.Stat(ctx, sourcePath); err != nil {
|
||||
if _, err := h.s3Service.Stat(ctx, sourcePath); err != nil {
|
||||
slog.Error("Source blob not found after multipart complete",
|
||||
"path", sourcePath,
|
||||
"error", err)
|
||||
@@ -264,9 +264,8 @@ func (h *XRPCHandler) CompleteMultipartUploadWithManager(ctx context.Context, up
|
||||
}
|
||||
slog.Debug("Source blob verified", "path", sourcePath)
|
||||
|
||||
// Move from temp to final digest location using driver
|
||||
// Driver handles path management correctly (including S3 prefix)
|
||||
if err := h.driver.Move(ctx, sourcePath, destPath); err != nil {
|
||||
// Move from temp to final digest location (S3 copy + delete)
|
||||
if err := h.s3Service.Move(ctx, sourcePath, destPath); err != nil {
|
||||
slog.Error("Failed to move blob",
|
||||
"source", sourcePath,
|
||||
"dest", destPath,
|
||||
|
||||
@@ -12,14 +12,12 @@ import (
|
||||
"atcr.io/pkg/hold/pds"
|
||||
"atcr.io/pkg/hold/quota"
|
||||
"atcr.io/pkg/s3"
|
||||
storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
)
|
||||
|
||||
// XRPCHandler handles OCI-specific XRPC endpoints for multipart uploads
|
||||
type XRPCHandler struct {
|
||||
driver storagedriver.StorageDriver
|
||||
s3Service s3.S3Service
|
||||
MultipartMgr *MultipartManager // Exported for access in route handlers
|
||||
pds *pds.HoldPDS
|
||||
@@ -30,9 +28,8 @@ type XRPCHandler struct {
|
||||
}
|
||||
|
||||
// NewXRPCHandler creates a new OCI XRPC handler
|
||||
func NewXRPCHandler(holdPDS *pds.HoldPDS, s3Service s3.S3Service, driver storagedriver.StorageDriver, enableBlueskyPosts bool, httpClient pds.HTTPClient, quotaMgr *quota.Manager) *XRPCHandler {
|
||||
func NewXRPCHandler(holdPDS *pds.HoldPDS, s3Service s3.S3Service, enableBlueskyPosts bool, httpClient pds.HTTPClient, quotaMgr *quota.Manager) *XRPCHandler {
|
||||
return &XRPCHandler{
|
||||
driver: driver,
|
||||
MultipartMgr: NewMultipartManager(),
|
||||
s3Service: s3Service,
|
||||
pds: holdPDS,
|
||||
@@ -366,7 +363,7 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
postURI, err = h.pds.CreateManifestPost(
|
||||
ctx,
|
||||
h.driver,
|
||||
&h.s3Service,
|
||||
req.Repository,
|
||||
req.Tag,
|
||||
userHandle,
|
||||
|
||||
+35
-214
@@ -2,7 +2,6 @@ package oci
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -10,17 +9,12 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"atcr.io/pkg/hold/pds"
|
||||
"atcr.io/pkg/s3"
|
||||
storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
|
||||
"github.com/distribution/distribution/v3/registry/storage/driver/factory"
|
||||
_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"
|
||||
)
|
||||
|
||||
// Shared test resources for OCI package
|
||||
@@ -68,148 +62,10 @@ func (m *mockPDSClient) Do(req *http.Request) (*http.Response, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// mockStorageDriver implements storagedriver.StorageDriver for testing
|
||||
type mockStorageDriver struct {
|
||||
mu sync.RWMutex
|
||||
blobs map[string][]byte
|
||||
|
||||
// Error injection for testing error handling
|
||||
StatError error
|
||||
MoveError error
|
||||
}
|
||||
|
||||
func newMockStorageDriver() *mockStorageDriver {
|
||||
return &mockStorageDriver{
|
||||
blobs: make(map[string][]byte),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockStorageDriver) Name() string { return "mock" }
|
||||
|
||||
func (m *mockStorageDriver) GetContent(ctx context.Context, path string) ([]byte, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if data, ok := m.blobs[path]; ok {
|
||||
return data, nil
|
||||
}
|
||||
return nil, storagedriver.PathNotFoundError{Path: path}
|
||||
}
|
||||
|
||||
func (m *mockStorageDriver) PutContent(ctx context.Context, path string, content []byte) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.blobs[path] = content
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockStorageDriver) Reader(ctx context.Context, path string, offset int64) (io.ReadCloser, error) {
|
||||
data, err := m.GetContent(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(data[offset:])), nil
|
||||
}
|
||||
|
||||
func (m *mockStorageDriver) Writer(ctx context.Context, path string, append bool) (storagedriver.FileWriter, error) {
|
||||
return &mockFileWriter{driver: m, path: path}, nil
|
||||
}
|
||||
|
||||
func (m *mockStorageDriver) Stat(ctx context.Context, path string) (storagedriver.FileInfo, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Check for injected error
|
||||
if m.StatError != nil {
|
||||
return nil, m.StatError
|
||||
}
|
||||
|
||||
if data, ok := m.blobs[path]; ok {
|
||||
return &mockFileInfo{path: path, size: int64(len(data))}, nil
|
||||
}
|
||||
return nil, storagedriver.PathNotFoundError{Path: path}
|
||||
}
|
||||
|
||||
func (m *mockStorageDriver) List(ctx context.Context, path string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockStorageDriver) Move(ctx context.Context, sourcePath string, destPath string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Check for injected error
|
||||
if m.MoveError != nil {
|
||||
return m.MoveError
|
||||
}
|
||||
|
||||
if data, ok := m.blobs[sourcePath]; ok {
|
||||
m.blobs[destPath] = data
|
||||
delete(m.blobs, sourcePath)
|
||||
return nil
|
||||
}
|
||||
return storagedriver.PathNotFoundError{Path: sourcePath}
|
||||
}
|
||||
|
||||
func (m *mockStorageDriver) Delete(ctx context.Context, path string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.blobs, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockStorageDriver) RedirectURL(r *http.Request, path string) (string, error) {
|
||||
return "", storagedriver.ErrUnsupportedMethod{}
|
||||
}
|
||||
|
||||
func (m *mockStorageDriver) Walk(ctx context.Context, path string, f storagedriver.WalkFn, options ...func(*storagedriver.WalkOptions)) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// mockFileWriter implements storagedriver.FileWriter
|
||||
type mockFileWriter struct {
|
||||
driver *mockStorageDriver
|
||||
path string
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (w *mockFileWriter) Write(p []byte) (int, error) {
|
||||
return w.buf.Write(p)
|
||||
}
|
||||
|
||||
func (w *mockFileWriter) Size() int64 {
|
||||
return int64(w.buf.Len())
|
||||
}
|
||||
|
||||
func (w *mockFileWriter) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *mockFileWriter) Cancel(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *mockFileWriter) Commit(ctx context.Context) error {
|
||||
w.driver.mu.Lock()
|
||||
defer w.driver.mu.Unlock()
|
||||
w.driver.blobs[w.path] = w.buf.Bytes()
|
||||
return nil
|
||||
}
|
||||
|
||||
// mockFileInfo implements storagedriver.FileInfo
|
||||
type mockFileInfo struct {
|
||||
path string
|
||||
size int64
|
||||
}
|
||||
|
||||
func (f *mockFileInfo) Path() string { return f.path }
|
||||
func (f *mockFileInfo) Size() int64 { return f.size }
|
||||
func (f *mockFileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (f *mockFileInfo) IsDir() bool { return false }
|
||||
|
||||
// setupTestOCIHandlerWithMockS3 creates a test OCI XRPC handler with mock S3
|
||||
// This does NOT require real S3 credentials - uses MockS3Client
|
||||
// Returns the handler, mock S3 client, and mock storage driver for test manipulation
|
||||
func setupTestOCIHandlerWithMockS3(t *testing.T) (*XRPCHandler, *s3.MockS3Client, *mockStorageDriver) {
|
||||
// Returns the handler and mock S3 client for test manipulation
|
||||
func setupTestOCIHandlerWithMockS3(t *testing.T) (*XRPCHandler, *s3.MockS3Client) {
|
||||
t.Helper()
|
||||
|
||||
// Create temp directory for PDS database
|
||||
@@ -226,9 +82,6 @@ func setupTestOCIHandlerWithMockS3(t *testing.T) (*XRPCHandler, *s3.MockS3Client
|
||||
PathPrefix: "test-prefix",
|
||||
}
|
||||
|
||||
// Create mock storage driver
|
||||
mockDriver := newMockStorageDriver()
|
||||
|
||||
// Create minimal PDS for DID/auth
|
||||
dbPath := ":memory:"
|
||||
keyPath := filepath.Join(tmpDir, "signing-key")
|
||||
@@ -268,9 +121,9 @@ func setupTestOCIHandlerWithMockS3(t *testing.T) (*XRPCHandler, *s3.MockS3Client
|
||||
mockClient := &mockPDSClient{}
|
||||
|
||||
// Create OCI handler with mock S3
|
||||
handler := NewXRPCHandler(holdPDS, s3Service, mockDriver, false, mockClient, nil)
|
||||
handler := NewXRPCHandler(holdPDS, s3Service, false, mockClient, nil)
|
||||
|
||||
return handler, mockS3Client, mockDriver
|
||||
return handler, mockS3Client
|
||||
}
|
||||
|
||||
// setupTestOCIHandlerWithS3 creates a test OCI XRPC handler with S3 driver
|
||||
@@ -307,12 +160,6 @@ func setupTestOCIHandlerWithS3(t *testing.T) (*XRPCHandler, bool) {
|
||||
}
|
||||
s3Params["rootdirectory"] = storageDir
|
||||
|
||||
driver, err := factory.Create(ctx, "s3", s3Params)
|
||||
if err != nil {
|
||||
t.Logf("Failed to create S3 storage driver: %v", err)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Create S3 service
|
||||
s3Service, err := s3.NewS3Service(s3Params)
|
||||
if err != nil {
|
||||
@@ -359,7 +206,7 @@ func setupTestOCIHandlerWithS3(t *testing.T) (*XRPCHandler, bool) {
|
||||
mockClient := &mockPDSClient{}
|
||||
|
||||
// Create OCI handler with S3
|
||||
handler := NewXRPCHandler(holdPDS, *s3Service, driver, false, mockClient, nil)
|
||||
handler := NewXRPCHandler(holdPDS, *s3Service, false, mockClient, nil)
|
||||
|
||||
return handler, true
|
||||
}
|
||||
@@ -392,7 +239,7 @@ func decodeJSONResponse(t *testing.T, w *httptest.ResponseRecorder, v any) {
|
||||
// Tests for HandleInitiateUpload - Mock S3 (no credentials required)
|
||||
|
||||
func TestHandleInitiateUpload_MockS3_Success(t *testing.T) {
|
||||
handler, mockS3Client, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, mockS3Client := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
req := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{
|
||||
"digest": "sha256:abc123",
|
||||
@@ -421,7 +268,7 @@ func TestHandleInitiateUpload_MockS3_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleInitiateUpload_MockS3_MissingDigest(t *testing.T) {
|
||||
handler, _, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
req := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{})
|
||||
addMockAuth(req)
|
||||
@@ -437,7 +284,7 @@ func TestHandleInitiateUpload_MockS3_MissingDigest(t *testing.T) {
|
||||
// Tests for full Mock S3 upload flow (no credentials required)
|
||||
|
||||
func TestFullMockS3UploadFlow(t *testing.T) {
|
||||
handler, mockS3Client, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, mockS3Client := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
// 1. Initiate upload
|
||||
initReq := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{
|
||||
@@ -506,7 +353,7 @@ func TestFullMockS3UploadFlow(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleGetPartUploadUrl_MockS3_InvalidSession(t *testing.T) {
|
||||
handler, _, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
req := makeJSONRequest("POST", atproto.HoldGetPartUploadURL, map[string]any{
|
||||
"uploadId": "invalid-upload-id",
|
||||
@@ -523,7 +370,7 @@ func TestHandleGetPartUploadUrl_MockS3_InvalidSession(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleAbortUpload_MockS3_InvalidSession(t *testing.T) {
|
||||
handler, _, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
req := makeJSONRequest("POST", atproto.HoldAbortUpload, map[string]string{
|
||||
"uploadId": "invalid-upload-id",
|
||||
@@ -764,7 +611,7 @@ func TestFullS3UploadFlow(t *testing.T) {
|
||||
// Tests for HandleCompleteUpload with Mock S3
|
||||
|
||||
func TestHandleCompleteUpload_MockS3_Success(t *testing.T) {
|
||||
handler, mockS3Client, mockDriver := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, mockS3Client := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
// 1. Initiate upload with temp path
|
||||
tempDigest := "uploads/temp-test-complete"
|
||||
@@ -796,12 +643,8 @@ func TestHandleCompleteUpload_MockS3_Success(t *testing.T) {
|
||||
t.Fatalf("Expected status 200 for part URL, got %d: %s", partW.Code, partW.Body.String())
|
||||
}
|
||||
|
||||
// 3. Pre-populate mock storage driver with temp blob (simulates S3 upload completing)
|
||||
// Path format: /docker/registry/v2/uploads/temp-{id}/data
|
||||
tempBlobPath := fmt.Sprintf("/docker/registry/v2/%s/data", tempDigest)
|
||||
mockDriver.PutContent(t.Context(), tempBlobPath, []byte("test blob content"))
|
||||
|
||||
// 4. Complete upload with parts
|
||||
// 3. Complete upload with parts
|
||||
// (Mock S3 CompleteMultipartUpload auto-populates object for Stat/Move)
|
||||
finalDigest := "sha256:abc123def456"
|
||||
completeReq := makeJSONRequest("POST", atproto.HoldCompleteUpload, map[string]any{
|
||||
"uploadId": uploadID,
|
||||
@@ -833,17 +676,15 @@ func TestHandleCompleteUpload_MockS3_Success(t *testing.T) {
|
||||
t.Errorf("Expected 1 Complete call, got %d", len(mockS3Client.CompleteCalls))
|
||||
}
|
||||
|
||||
// 6. Verify blob was moved to final location
|
||||
// Final path format: /docker/registry/v2/blobs/sha256/ab/abc123def456/data
|
||||
finalBlobPath := "/docker/registry/v2/blobs/sha256/ab/abc123def456/data"
|
||||
_, err := mockDriver.Stat(t.Context(), finalBlobPath)
|
||||
if err != nil {
|
||||
t.Errorf("Expected blob at final location %s, got error: %v", finalBlobPath, err)
|
||||
// 6. Verify blob was moved to final location in mock S3
|
||||
finalS3Key := "test-prefix/docker/registry/v2/blobs/sha256/ab/abc123def456/data"
|
||||
if mockS3Client.GetObject(finalS3Key) == nil {
|
||||
t.Errorf("Expected blob at final S3 key %s", finalS3Key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCompleteUpload_MockS3_InvalidSession(t *testing.T) {
|
||||
handler, _, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
req := makeJSONRequest("POST", atproto.HoldCompleteUpload, map[string]any{
|
||||
"uploadId": "non-existent-upload-id",
|
||||
@@ -863,7 +704,7 @@ func TestHandleCompleteUpload_MockS3_InvalidSession(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleCompleteUpload_MockS3_MissingParams(t *testing.T) {
|
||||
handler, _, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -891,7 +732,7 @@ func TestHandleCompleteUpload_MockS3_MissingParams(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleCompleteUpload_MockS3_ETagNormalization(t *testing.T) {
|
||||
handler, mockS3Client, mockDriver := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, mockS3Client := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
// Setup upload session
|
||||
tempDigest := "uploads/temp-etag-test"
|
||||
@@ -906,10 +747,6 @@ func TestHandleCompleteUpload_MockS3_ETagNormalization(t *testing.T) {
|
||||
decodeJSONResponse(t, initW, &initResp)
|
||||
uploadID := initResp["uploadId"].(string)
|
||||
|
||||
// Pre-populate mock driver
|
||||
tempBlobPath := fmt.Sprintf("/docker/registry/v2/%s/data", tempDigest)
|
||||
mockDriver.PutContent(t.Context(), tempBlobPath, []byte("test"))
|
||||
|
||||
// Complete with unquoted ETags
|
||||
completeReq := makeJSONRequest("POST", atproto.HoldCompleteUpload, map[string]any{
|
||||
"uploadId": uploadID,
|
||||
@@ -938,7 +775,7 @@ func TestHandleCompleteUpload_MockS3_ETagNormalization(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleCompleteUpload_MockS3_S3Error(t *testing.T) {
|
||||
handler, mockS3Client, mockDriver := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, mockS3Client := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
// Setup upload session
|
||||
tempDigest := "uploads/temp-s3-error"
|
||||
@@ -953,10 +790,6 @@ func TestHandleCompleteUpload_MockS3_S3Error(t *testing.T) {
|
||||
decodeJSONResponse(t, initW, &initResp)
|
||||
uploadID := initResp["uploadId"].(string)
|
||||
|
||||
// Pre-populate mock driver
|
||||
tempBlobPath := fmt.Sprintf("/docker/registry/v2/%s/data", tempDigest)
|
||||
mockDriver.PutContent(t.Context(), tempBlobPath, []byte("test"))
|
||||
|
||||
// Inject S3 error
|
||||
mockS3Client.CompleteError = fmt.Errorf("simulated S3 CompleteMultipartUpload failure")
|
||||
|
||||
@@ -983,7 +816,7 @@ func TestHandleCompleteUpload_MockS3_S3Error(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleCompleteUpload_MockS3_StatError(t *testing.T) {
|
||||
handler, _, mockDriver := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, mockS3Client := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
// Setup upload session
|
||||
tempDigest := "uploads/temp-stat-error"
|
||||
@@ -998,12 +831,8 @@ func TestHandleCompleteUpload_MockS3_StatError(t *testing.T) {
|
||||
decodeJSONResponse(t, initW, &initResp)
|
||||
uploadID := initResp["uploadId"].(string)
|
||||
|
||||
// Pre-populate mock driver (so S3 complete succeeds)
|
||||
tempBlobPath := fmt.Sprintf("/docker/registry/v2/%s/data", tempDigest)
|
||||
mockDriver.PutContent(t.Context(), tempBlobPath, []byte("test"))
|
||||
|
||||
// Inject Stat error (simulates blob not found after S3 complete)
|
||||
mockDriver.StatError = fmt.Errorf("simulated stat failure")
|
||||
// Inject HeadObject error (simulates blob not found after S3 complete)
|
||||
mockS3Client.HeadObjectError = fmt.Errorf("simulated stat failure")
|
||||
|
||||
// Complete upload should fail
|
||||
completeReq := makeJSONRequest("POST", atproto.HoldCompleteUpload, map[string]any{
|
||||
@@ -1023,7 +852,7 @@ func TestHandleCompleteUpload_MockS3_StatError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleCompleteUpload_MockS3_MoveError(t *testing.T) {
|
||||
handler, _, mockDriver := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, mockS3Client := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
// Setup upload session
|
||||
tempDigest := "uploads/temp-move-error"
|
||||
@@ -1038,12 +867,8 @@ func TestHandleCompleteUpload_MockS3_MoveError(t *testing.T) {
|
||||
decodeJSONResponse(t, initW, &initResp)
|
||||
uploadID := initResp["uploadId"].(string)
|
||||
|
||||
// Pre-populate mock driver
|
||||
tempBlobPath := fmt.Sprintf("/docker/registry/v2/%s/data", tempDigest)
|
||||
mockDriver.PutContent(t.Context(), tempBlobPath, []byte("test"))
|
||||
|
||||
// Inject Move error
|
||||
mockDriver.MoveError = fmt.Errorf("simulated move failure")
|
||||
// Inject CopyObject error (Move = Copy + Delete, so Copy error simulates move failure)
|
||||
mockS3Client.CopyObjectError = fmt.Errorf("simulated move failure")
|
||||
|
||||
// Complete upload should fail
|
||||
completeReq := makeJSONRequest("POST", atproto.HoldCompleteUpload, map[string]any{
|
||||
@@ -1068,7 +893,7 @@ func TestHandleCompleteUpload_MockS3_MoveError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleCompleteUpload_MockS3_UnsortedParts(t *testing.T) {
|
||||
handler, mockS3Client, mockDriver := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, mockS3Client := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
// Setup upload session
|
||||
tempDigest := "uploads/temp-unsorted"
|
||||
@@ -1083,10 +908,6 @@ func TestHandleCompleteUpload_MockS3_UnsortedParts(t *testing.T) {
|
||||
decodeJSONResponse(t, initW, &initResp)
|
||||
uploadID := initResp["uploadId"].(string)
|
||||
|
||||
// Pre-populate mock driver
|
||||
tempBlobPath := fmt.Sprintf("/docker/registry/v2/%s/data", tempDigest)
|
||||
mockDriver.PutContent(t.Context(), tempBlobPath, []byte("test"))
|
||||
|
||||
// Complete with unsorted parts (3, 1, 2) - handler should sort them
|
||||
completeReq := makeJSONRequest("POST", atproto.HoldCompleteUpload, map[string]any{
|
||||
"uploadId": uploadID,
|
||||
@@ -1117,7 +938,7 @@ func TestHandleCompleteUpload_MockS3_UnsortedParts(t *testing.T) {
|
||||
// Tests for HandleInitiateUpload edge cases
|
||||
|
||||
func TestHandleInitiateUpload_MockS3_S3Error(t *testing.T) {
|
||||
handler, mockS3Client, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, mockS3Client := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
// Inject S3 error
|
||||
mockS3Client.CreateMultipartError = fmt.Errorf("simulated S3 CreateMultipartUpload failure")
|
||||
@@ -1141,7 +962,7 @@ func TestHandleInitiateUpload_MockS3_S3Error(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleInitiateUpload_MockS3_WhitespaceDigest(t *testing.T) {
|
||||
handler, _, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
req := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{
|
||||
"digest": " ", // Whitespace only
|
||||
@@ -1162,7 +983,7 @@ func TestHandleInitiateUpload_MockS3_WhitespaceDigest(t *testing.T) {
|
||||
// Tests for HandleGetPartUploadURL edge cases
|
||||
|
||||
func TestHandleGetPartUploadUrl_MockS3_MissingParams(t *testing.T) {
|
||||
handler, _, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -1189,7 +1010,7 @@ func TestHandleGetPartUploadUrl_MockS3_MissingParams(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleGetPartUploadUrl_MockS3_ValidSession(t *testing.T) {
|
||||
handler, mockS3Client, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, mockS3Client := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
// First initiate an upload
|
||||
initReq := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{
|
||||
@@ -1237,7 +1058,7 @@ func TestHandleGetPartUploadUrl_MockS3_ValidSession(t *testing.T) {
|
||||
// Tests for HandleAbortUpload edge cases
|
||||
|
||||
func TestHandleAbortUpload_MockS3_MissingUploadId(t *testing.T) {
|
||||
handler, _, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
req := makeJSONRequest("POST", atproto.HoldAbortUpload, map[string]string{})
|
||||
addMockAuth(req)
|
||||
@@ -1251,7 +1072,7 @@ func TestHandleAbortUpload_MockS3_MissingUploadId(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleAbortUpload_MockS3_S3Error(t *testing.T) {
|
||||
handler, mockS3Client, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, mockS3Client := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
// First initiate an upload
|
||||
initReq := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{
|
||||
@@ -1288,7 +1109,7 @@ func TestHandleAbortUpload_MockS3_S3Error(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleAbortUpload_MockS3_ValidSession(t *testing.T) {
|
||||
handler, mockS3Client, _ := setupTestOCIHandlerWithMockS3(t)
|
||||
handler, mockS3Client := setupTestOCIHandlerWithMockS3(t)
|
||||
|
||||
// First initiate an upload
|
||||
initReq := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/s3"
|
||||
bsky "github.com/bluesky-social/indigo/api/bsky"
|
||||
"github.com/distribution/distribution/v3/registry/storage/driver"
|
||||
)
|
||||
|
||||
// CreateManifestPost creates a Bluesky post announcing a manifest upload
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
// artifactType is "container-image", "helm-chart", or "unknown"
|
||||
func (p *HoldPDS) CreateManifestPost(
|
||||
ctx context.Context,
|
||||
storageDriver driver.StorageDriver,
|
||||
s3svc *s3.S3Service,
|
||||
repository, tag, userHandle, userDID, digest string,
|
||||
totalSize int64,
|
||||
platforms []string,
|
||||
@@ -50,7 +50,7 @@ func (p *HoldPDS) CreateManifestPost(
|
||||
slog.Warn("Failed to fetch OG image, posting without embed", "error", err)
|
||||
} else {
|
||||
// Upload OG image as blob
|
||||
thumbBlob, err := uploadBlobToStorage(ctx, storageDriver, p.did, ogImageData, "image/png")
|
||||
thumbBlob, err := uploadBlobToStorage(ctx, s3svc, p.did, ogImageData, "image/png")
|
||||
if err != nil {
|
||||
slog.Warn("Failed to upload OG image blob", "error", err)
|
||||
} else {
|
||||
|
||||
+9
-29
@@ -1,7 +1,6 @@
|
||||
package pds
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
@@ -11,9 +10,9 @@ import (
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/s3"
|
||||
bsky "github.com/bluesky-social/indigo/api/bsky"
|
||||
lexutil "github.com/bluesky-social/indigo/lex/util"
|
||||
"github.com/distribution/distribution/v3/registry/storage/driver"
|
||||
"github.com/ipfs/go-cid"
|
||||
"github.com/multiformats/go-multihash"
|
||||
)
|
||||
@@ -68,9 +67,9 @@ func downloadImage(ctx context.Context, url string) ([]byte, string, error) {
|
||||
return data, contentType, nil
|
||||
}
|
||||
|
||||
// uploadBlobToStorage uploads a blob to the hold's storage and returns a blob reference
|
||||
// This stores the blob at the ATProto path for the hold's DID
|
||||
func uploadBlobToStorage(ctx context.Context, storageDriver driver.StorageDriver, did string, data []byte, mimeType string) (*lexutil.LexBlob, error) {
|
||||
// uploadBlobToStorage uploads a blob to the hold's S3 storage and returns a blob reference.
|
||||
// This stores the blob at the ATProto path for the hold's DID.
|
||||
func uploadBlobToStorage(ctx context.Context, s3svc *s3.S3Service, did string, data []byte, mimeType string) (*lexutil.LexBlob, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("empty blob data")
|
||||
}
|
||||
@@ -90,33 +89,14 @@ func uploadBlobToStorage(ctx context.Context, storageDriver driver.StorageDriver
|
||||
// ATProto uses CIDv1 with raw codec for blobs
|
||||
blobCID := cid.NewCidV1(0x55, mh)
|
||||
|
||||
// Store blob via distribution driver at ATProto path
|
||||
// Store blob via S3 at ATProto path
|
||||
path := atprotoBlobPath(did, blobCID.String())
|
||||
|
||||
// Write blob to storage using distribution driver
|
||||
writer, err := storageDriver.Writer(ctx, path, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create writer: %w", err)
|
||||
}
|
||||
|
||||
// Write data
|
||||
n, err := io.Copy(writer, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
writer.Cancel(ctx)
|
||||
return nil, fmt.Errorf("failed to write blob: %w", err)
|
||||
}
|
||||
|
||||
// Commit the write
|
||||
if err := writer.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to commit blob: %w", err)
|
||||
}
|
||||
|
||||
if n != size {
|
||||
return nil, fmt.Errorf("size mismatch: wrote %d bytes, expected %d", n, size)
|
||||
if err := s3svc.PutBytes(ctx, path, data, mimeType); err != nil {
|
||||
return nil, fmt.Errorf("failed to put blob: %w", err)
|
||||
}
|
||||
|
||||
// Create blob reference in the format expected by bsky.ActorProfile
|
||||
// LexLink is a type alias for cid.Cid
|
||||
lexLink := lexutil.LexLink(blobCID)
|
||||
blob := &lexutil.LexBlob{
|
||||
Ref: lexLink,
|
||||
@@ -129,7 +109,7 @@ func uploadBlobToStorage(ctx context.Context, storageDriver driver.StorageDriver
|
||||
|
||||
// CreateProfileRecord creates the app.bsky.actor.profile record for the hold
|
||||
// This will FAIL if the profile record already exists.
|
||||
func (p *HoldPDS) CreateProfileRecord(ctx context.Context, storageDriver driver.StorageDriver, displayName, description, avatarURL string) (cid.Cid, error) {
|
||||
func (p *HoldPDS) CreateProfileRecord(ctx context.Context, s3svc *s3.S3Service, displayName, description, avatarURL string) (cid.Cid, error) {
|
||||
// Create profile struct
|
||||
profile := &bsky.ActorProfile{
|
||||
DisplayName: &displayName,
|
||||
@@ -147,7 +127,7 @@ func (p *HoldPDS) CreateProfileRecord(ctx context.Context, storageDriver driver.
|
||||
slog.Debug("Uploading avatar blob",
|
||||
"size", len(imageData),
|
||||
"mimeType", mimeType)
|
||||
avatarBlob, err := uploadBlobToStorage(ctx, storageDriver, p.did, imageData, mimeType)
|
||||
avatarBlob, err := uploadBlobToStorage(ctx, s3svc, p.did, imageData, mimeType)
|
||||
if err != nil {
|
||||
return cid.Undef, fmt.Errorf("failed to upload avatar blob: %w", err)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/s3"
|
||||
lexutil "github.com/bluesky-social/indigo/lex/util"
|
||||
storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
@@ -28,7 +28,7 @@ type ScanBroadcaster struct {
|
||||
db *sql.DB
|
||||
holdDID string
|
||||
holdEndpoint string
|
||||
driver storagedriver.StorageDriver
|
||||
s3 *s3.S3Service
|
||||
pds *HoldPDS
|
||||
ackTimeout time.Duration
|
||||
secret string // Shared secret for scanner authentication
|
||||
@@ -80,7 +80,7 @@ type VulnerabilitySummary struct {
|
||||
|
||||
// NewScanBroadcaster creates a new scan job broadcaster
|
||||
// dbPath should point to a SQLite database file (e.g., "/path/to/pds/db.sqlite3")
|
||||
func NewScanBroadcaster(holdDID, holdEndpoint, secret, dbPath string, driver storagedriver.StorageDriver, holdPDS *HoldPDS) (*ScanBroadcaster, error) {
|
||||
func NewScanBroadcaster(holdDID, holdEndpoint, secret, dbPath string, s3svc *s3.S3Service, holdPDS *HoldPDS) (*ScanBroadcaster, error) {
|
||||
dsn := dbPath
|
||||
if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file:") {
|
||||
dsn = "file:" + dbPath
|
||||
@@ -99,7 +99,7 @@ func NewScanBroadcaster(holdDID, holdEndpoint, secret, dbPath string, driver sto
|
||||
db: db,
|
||||
holdDID: holdDID,
|
||||
holdEndpoint: holdEndpoint,
|
||||
driver: driver,
|
||||
s3: s3svc,
|
||||
pds: holdPDS,
|
||||
ackTimeout: 5 * time.Minute,
|
||||
secret: secret,
|
||||
@@ -119,13 +119,13 @@ func NewScanBroadcaster(holdDID, holdEndpoint, secret, dbPath string, driver sto
|
||||
|
||||
// NewScanBroadcasterWithDB creates a scan job broadcaster using an existing *sql.DB connection.
|
||||
// The caller is responsible for the DB lifecycle.
|
||||
func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret string, db *sql.DB, driver storagedriver.StorageDriver, holdPDS *HoldPDS) (*ScanBroadcaster, error) {
|
||||
func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret string, db *sql.DB, s3svc *s3.S3Service, holdPDS *HoldPDS) (*ScanBroadcaster, error) {
|
||||
sb := &ScanBroadcaster{
|
||||
subscribers: make([]*ScanSubscriber, 0),
|
||||
db: db,
|
||||
holdDID: holdDID,
|
||||
holdEndpoint: holdEndpoint,
|
||||
driver: driver,
|
||||
s3: s3svc,
|
||||
pds: holdPDS,
|
||||
ackTimeout: 5 * time.Minute,
|
||||
secret: secret,
|
||||
@@ -424,7 +424,7 @@ func (sb *ScanBroadcaster) handleResult(sub *ScanSubscriber, msg ScannerMessage)
|
||||
// Upload SBOM as a blob to the hold's PDS blob storage (like manifest blobs)
|
||||
var sbomBlob *lexutil.LexBlob
|
||||
if msg.SBOM != "" {
|
||||
blob, err := uploadBlobToStorage(ctx, sb.driver, sb.holdDID, []byte(msg.SBOM), "application/spdx+json")
|
||||
blob, err := uploadBlobToStorage(ctx, sb.s3, sb.holdDID, []byte(msg.SBOM), "application/spdx+json")
|
||||
if err != nil {
|
||||
slog.Error("Failed to upload SBOM blob to PDS storage",
|
||||
"seq", msg.Seq,
|
||||
@@ -434,11 +434,24 @@ func (sb *ScanBroadcaster) handleResult(sub *ScanSubscriber, msg ScannerMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Upload vulnerability report as a blob (full Grype JSON with CVE details)
|
||||
var vulnReportBlob *lexutil.LexBlob
|
||||
if msg.VulnReport != "" {
|
||||
blob, err := uploadBlobToStorage(ctx, sb.s3, sb.holdDID, []byte(msg.VulnReport), "application/vnd.atcr.vulnerabilities+json")
|
||||
if err != nil {
|
||||
slog.Error("Failed to upload VulnReport blob to PDS storage",
|
||||
"seq", msg.Seq,
|
||||
"error", err)
|
||||
} else {
|
||||
vulnReportBlob = blob
|
||||
}
|
||||
}
|
||||
|
||||
// Store scan result as a record in the hold's embedded PDS
|
||||
if msg.Summary != nil {
|
||||
scanRecord := atproto.NewScanRecord(
|
||||
manifestDigest, repository, userDID,
|
||||
sbomBlob,
|
||||
sbomBlob, vulnReportBlob,
|
||||
msg.Summary.Critical, msg.Summary.High, msg.Summary.Medium, msg.Summary.Low, msg.Summary.Total,
|
||||
"atcr-scanner-v1.0.0",
|
||||
)
|
||||
|
||||
@@ -13,11 +13,11 @@ import (
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
holddb "atcr.io/pkg/hold/db"
|
||||
"atcr.io/pkg/s3"
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
lexutil "github.com/bluesky-social/indigo/lex/util"
|
||||
"github.com/bluesky-social/indigo/models"
|
||||
"github.com/bluesky-social/indigo/repo"
|
||||
"github.com/distribution/distribution/v3/registry/storage/driver"
|
||||
"github.com/ipfs/go-cid"
|
||||
)
|
||||
|
||||
@@ -231,7 +231,7 @@ func (p *HoldPDS) GetRecordBytes(ctx context.Context, recordPath string) (cid.Ci
|
||||
}
|
||||
|
||||
// Bootstrap initializes the hold with the captain record, owner as first crew member, and profile
|
||||
func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDriver, ownerDID string, public bool, allowAllCrew bool, avatarURL, region string) error {
|
||||
func (p *HoldPDS) Bootstrap(ctx context.Context, s3svc *s3.S3Service, ownerDID string, public bool, allowAllCrew bool, avatarURL, region string) error {
|
||||
if ownerDID == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -317,15 +317,15 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDri
|
||||
|
||||
// Create Bluesky profile record (idempotent - check if exists first)
|
||||
// This runs even if captain exists (for existing holds being upgraded)
|
||||
// Skip if no storage driver (e.g., in tests)
|
||||
if storageDriver != nil {
|
||||
// Skip if no S3 service (e.g., in tests)
|
||||
if s3svc != nil {
|
||||
_, _, err = p.GetProfileRecord(ctx)
|
||||
if err != nil {
|
||||
// Bluesky profile doesn't exist, create it
|
||||
displayName := "Cargo Hold"
|
||||
description := "ahoy from the cargo hold"
|
||||
|
||||
_, err = p.CreateProfileRecord(ctx, storageDriver, displayName, description, avatarURL)
|
||||
_, err = p.CreateProfileRecord(ctx, s3svc, displayName, description, avatarURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create bluesky profile record: %w", err)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func TestStatusPost(t *testing.T) {
|
||||
}
|
||||
|
||||
// Create handler for XRPC endpoints
|
||||
handler := NewXRPCHandler(holdPDS, s3.S3Service{}, nil, nil, &mockPDSClient{}, nil)
|
||||
handler := NewXRPCHandler(holdPDS, s3.S3Service{}, nil, &mockPDSClient{}, nil)
|
||||
|
||||
// Helper function to list posts via XRPC
|
||||
listPosts := func() ([]map[string]any, error) {
|
||||
@@ -283,7 +283,7 @@ func TestMain(m *testing.M) {
|
||||
}
|
||||
|
||||
// Create shared handler
|
||||
sharedHandler = NewXRPCHandler(sharedPDS, s3.S3Service{}, nil, nil, &mockPDSClient{}, nil)
|
||||
sharedHandler = NewXRPCHandler(sharedPDS, s3.S3Service{}, nil, &mockPDSClient{}, nil)
|
||||
|
||||
// Run tests
|
||||
code := m.Run()
|
||||
|
||||
+10
-34
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/bluesky-social/indigo/api/bsky"
|
||||
lexutil "github.com/bluesky-social/indigo/lex/util"
|
||||
"github.com/bluesky-social/indigo/repo"
|
||||
"github.com/distribution/distribution/v3/registry/storage/driver"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
"github.com/gorilla/websocket"
|
||||
@@ -46,7 +45,6 @@ const (
|
||||
type XRPCHandler struct {
|
||||
pds *HoldPDS
|
||||
s3Service s3.S3Service
|
||||
storageDriver driver.StorageDriver
|
||||
broadcaster *EventBroadcaster
|
||||
scanBroadcaster *ScanBroadcaster // Scan job dispatcher for connected scanners
|
||||
httpClient HTTPClient // For testing - allows injecting mock HTTP client
|
||||
@@ -68,14 +66,13 @@ type PartUploadInfo struct {
|
||||
}
|
||||
|
||||
// NewXRPCHandler creates a new XRPC handler
|
||||
func NewXRPCHandler(pds *HoldPDS, s3Service s3.S3Service, storageDriver driver.StorageDriver, broadcaster *EventBroadcaster, httpClient HTTPClient, quotaMgr *quota.Manager) *XRPCHandler {
|
||||
func NewXRPCHandler(pds *HoldPDS, s3Service s3.S3Service, broadcaster *EventBroadcaster, httpClient HTTPClient, quotaMgr *quota.Manager) *XRPCHandler {
|
||||
return &XRPCHandler{
|
||||
pds: pds,
|
||||
s3Service: s3Service,
|
||||
storageDriver: storageDriver,
|
||||
broadcaster: broadcaster,
|
||||
httpClient: httpClient,
|
||||
quotaMgr: quotaMgr,
|
||||
pds: pds,
|
||||
s3Service: s3Service,
|
||||
broadcaster: broadcaster,
|
||||
httpClient: httpClient,
|
||||
quotaMgr: quotaMgr,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1052,32 +1049,11 @@ func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) {
|
||||
// ATProto uses CIDv1 with raw codec for blobs
|
||||
blobCID := cid.NewCidV1(0x55, mh)
|
||||
|
||||
// Store blob via distribution driver at ATProto path
|
||||
// Store blob via S3 at ATProto path
|
||||
path := atprotoBlobPath(did, blobCID.String())
|
||||
|
||||
// Write blob to storage using distribution driver
|
||||
writer, err := h.storageDriver.Writer(r.Context(), path, false)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to create writer: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Write data
|
||||
n, err := io.Copy(writer, bytes.NewReader(blobData))
|
||||
if err != nil {
|
||||
writer.Cancel(r.Context())
|
||||
http.Error(w, fmt.Sprintf("failed to write blob: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Commit the write
|
||||
if err := writer.Commit(r.Context()); err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to commit blob: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if n != size {
|
||||
http.Error(w, fmt.Sprintf("size mismatch: wrote %d bytes, expected %d", n, size), http.StatusInternalServerError)
|
||||
if err := h.s3Service.PutBytes(r.Context(), path, blobData, "application/octet-stream"); err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to put blob: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1259,7 +1235,7 @@ func (h *XRPCHandler) HandleListBlobs(w http.ResponseWriter, r *http.Request) {
|
||||
safeDID := strings.ReplaceAll(did, ":", "-")
|
||||
blobsPath := fmt.Sprintf("/repos/%s/blobs", safeDID)
|
||||
|
||||
entries, err := h.storageDriver.List(r.Context(), blobsPath)
|
||||
entries, err := h.s3Service.ListPrefix(r.Context(), blobsPath)
|
||||
if err != nil {
|
||||
// Path doesn't exist = no blobs, return empty list
|
||||
render.JSON(w, r, map[string]any{"cids": []string{}})
|
||||
|
||||
+24
-62
@@ -18,8 +18,6 @@ import (
|
||||
"atcr.io/pkg/s3"
|
||||
indigoAtproto "github.com/bluesky-social/indigo/api/atproto"
|
||||
"github.com/bluesky-social/indigo/events"
|
||||
"github.com/distribution/distribution/v3/registry/storage/driver/factory"
|
||||
_ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/ipfs/go-cid"
|
||||
@@ -76,7 +74,7 @@ func setupTestXRPCHandler(t *testing.T) (*XRPCHandler, context.Context) {
|
||||
mockS3 := s3.S3Service{}
|
||||
|
||||
// Create XRPC handler with mock HTTP client
|
||||
handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient, nil)
|
||||
handler := NewXRPCHandler(pds, mockS3, nil, mockClient, nil)
|
||||
|
||||
return handler, ctx
|
||||
}
|
||||
@@ -143,7 +141,7 @@ func setupTestXRPCHandlerWithIndex(t *testing.T) (*XRPCHandler, context.Context)
|
||||
mockS3 := s3.S3Service{}
|
||||
|
||||
// Create XRPC handler with mock HTTP client
|
||||
handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient, nil)
|
||||
handler := NewXRPCHandler(pds, mockS3, nil, mockClient, nil)
|
||||
|
||||
return handler, ctx
|
||||
}
|
||||
@@ -753,7 +751,7 @@ func TestHandleListRecords_EmptyCollection(t *testing.T) {
|
||||
pds, ctx := setupTestPDS(t) // Don't bootstrap - no records created yet
|
||||
mockClient := &mockPDSClient{}
|
||||
mockS3 := s3.S3Service{}
|
||||
handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient, nil)
|
||||
handler := NewXRPCHandler(pds, mockS3, nil, mockClient, nil)
|
||||
|
||||
// Initialize repo manually (setupTestPDS doesn't call Bootstrap, so no crew members)
|
||||
err := pds.repomgr.InitNewActor(ctx, pds.uid, "", pds.did, "", "", "")
|
||||
@@ -1231,7 +1229,7 @@ func TestHandleListRepos_EmptyRepo(t *testing.T) {
|
||||
pds, ctx := setupTestPDS(t) // Don't bootstrap
|
||||
mockClient := &mockPDSClient{}
|
||||
mockS3 := s3.S3Service{}
|
||||
handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient, nil)
|
||||
handler := NewXRPCHandler(pds, mockS3, nil, mockClient, nil)
|
||||
|
||||
// setupTestPDS creates the PDS/database but doesn't initialize the repo
|
||||
// Check if implementation returns repos before initialization
|
||||
@@ -1317,7 +1315,7 @@ func TestHandleGetRepoStatus_EmptyRepo(t *testing.T) {
|
||||
pds, ctx := setupTestPDS(t) // Don't bootstrap
|
||||
mockClient := &mockPDSClient{}
|
||||
mockS3 := s3.S3Service{}
|
||||
handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient, nil)
|
||||
handler := NewXRPCHandler(pds, mockS3, nil, mockClient, nil)
|
||||
holdDID := "did:web:hold.example.com"
|
||||
|
||||
// Initialize repo but don't add any records
|
||||
@@ -1960,27 +1958,6 @@ func TestHandleAtprotoDID(t *testing.T) {
|
||||
// Mock S3 Service for testing blob endpoints
|
||||
|
||||
// mockS3Service is a simple mock that tracks calls and returns test URLs
|
||||
type mockS3Service struct {
|
||||
// Track calls
|
||||
downloadCalls []string // Track digests requested for download
|
||||
}
|
||||
|
||||
func newMockS3Service() *mockS3Service {
|
||||
return &mockS3Service{
|
||||
downloadCalls: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
// toS3Service converts the mock to an s3.S3Service
|
||||
// Returns empty s3.S3Service since we're not testing S3 presigned URLs in these tests
|
||||
func (m *mockS3Service) toS3Service() s3.S3Service {
|
||||
return s3.S3Service{
|
||||
Client: nil, // Not testing presigned URLs
|
||||
Bucket: "",
|
||||
PathPrefix: "",
|
||||
}
|
||||
}
|
||||
|
||||
// setupTestXRPCHandlerWithMockS3 creates handler with MockS3Client for testing presigned URLs
|
||||
func setupTestXRPCHandlerWithMockS3(t *testing.T) (*XRPCHandler, *s3.MockS3Client, context.Context) {
|
||||
t.Helper()
|
||||
@@ -2029,27 +2006,17 @@ func setupTestXRPCHandlerWithMockS3(t *testing.T) (*XRPCHandler, *s3.MockS3Clien
|
||||
PathPrefix: "test-prefix",
|
||||
}
|
||||
|
||||
// Create filesystem storage driver for tests
|
||||
storageDir := filepath.Join(tmpDir, "storage")
|
||||
params := map[string]any{
|
||||
"rootdirectory": storageDir,
|
||||
}
|
||||
driver, err := factory.Create(ctx, "filesystem", params)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create storage driver: %v", err)
|
||||
}
|
||||
|
||||
// Create mock PDS client for DPoP validation
|
||||
mockClient := &mockPDSClient{}
|
||||
|
||||
// Create XRPC handler with mock S3 client and real filesystem driver
|
||||
handler := NewXRPCHandler(pds, s3Service, driver, nil, mockClient, nil)
|
||||
// Create XRPC handler with mock S3 client
|
||||
handler := NewXRPCHandler(pds, s3Service, nil, mockClient, nil)
|
||||
|
||||
return handler, mockS3Client, ctx
|
||||
}
|
||||
|
||||
// setupTestXRPCHandlerWithBlobs creates handler with mock s3 service and real filesystem driver
|
||||
func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *mockS3Service, context.Context) {
|
||||
// setupTestXRPCHandlerWithBlobs creates handler with MockS3Client for upload/list testing
|
||||
func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *s3.MockS3Client, context.Context) {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -2088,26 +2055,21 @@ func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *mockS3Service,
|
||||
t.Fatalf("Failed to bootstrap PDS: %v", err)
|
||||
}
|
||||
|
||||
// Create mock s3 service that returns test URLs
|
||||
mockS3Svc := newMockS3Service()
|
||||
|
||||
// Create filesystem storage driver for tests
|
||||
storageDir := filepath.Join(tmpDir, "storage")
|
||||
params := map[string]any{
|
||||
"rootdirectory": storageDir,
|
||||
}
|
||||
driver, err := factory.Create(ctx, "filesystem", params)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create storage driver: %v", err)
|
||||
// Create MockS3Client for blob upload/list
|
||||
mockS3Client := s3.NewMockS3Client("https://mock-s3.example.com")
|
||||
s3Service := s3.S3Service{
|
||||
Client: mockS3Client,
|
||||
Bucket: "test-bucket",
|
||||
PathPrefix: "",
|
||||
}
|
||||
|
||||
// Create mock PDS client for DPoP validation
|
||||
mockClient := &mockPDSClient{}
|
||||
|
||||
// Create XRPC handler with mock s3 service and real filesystem driver
|
||||
handler := NewXRPCHandler(pds, mockS3Svc.toS3Service(), driver, nil, mockClient, nil)
|
||||
// Create XRPC handler
|
||||
handler := NewXRPCHandler(pds, s3Service, nil, mockClient, nil)
|
||||
|
||||
return handler, mockS3Svc, ctx
|
||||
return handler, mockS3Client, ctx
|
||||
}
|
||||
|
||||
// Tests for HandleUploadBlob
|
||||
@@ -2391,9 +2353,9 @@ func TestHandleGetBlob(t *testing.T) {
|
||||
t.Error("Expected Location header in 307 redirect")
|
||||
}
|
||||
|
||||
// Should be XRPC proxy URL since we don't have S3 client
|
||||
if !strings.Contains(location, "/xrpc/com.atproto.sync.getBlob") {
|
||||
t.Errorf("Expected XRPC proxy URL, got: %s", location)
|
||||
// Should be a presigned URL from the mock S3 client
|
||||
if !strings.Contains(location, "mock-s3.example.com") {
|
||||
t.Errorf("Expected presigned S3 URL, got: %s", location)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2457,9 +2419,9 @@ func TestHandleGetBlob_HeadMethod(t *testing.T) {
|
||||
t.Error("Expected Location header in 307 redirect")
|
||||
}
|
||||
|
||||
// Should be XRPC proxy URL since we don't have S3 client
|
||||
if !strings.Contains(location, "/xrpc/com.atproto.sync.getBlob") {
|
||||
t.Errorf("Expected XRPC proxy URL, got: %s", location)
|
||||
// Should be a presigned URL from the mock S3 client
|
||||
if !strings.Contains(location, "mock-s3.example.com") {
|
||||
t.Errorf("Expected presigned S3 URL, got: %s", location)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-24
@@ -21,9 +21,6 @@ import (
|
||||
"atcr.io/pkg/logging"
|
||||
"atcr.io/pkg/s3"
|
||||
|
||||
"github.com/distribution/distribution/v3/registry/storage/driver/factory"
|
||||
_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
@@ -72,6 +69,7 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
|
||||
|
||||
// Initialize embedded PDS if database path is configured
|
||||
var xrpcHandler *pds.XRPCHandler
|
||||
var s3Service *s3.S3Service
|
||||
if cfg.Database.Path != "" {
|
||||
holdDID := pds.GenerateDIDFromURL(cfg.Server.PublicURL)
|
||||
slog.Info("Initializing embedded PDS", "did", holdDID)
|
||||
@@ -109,14 +107,14 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
|
||||
s.broadcaster = pds.NewEventBroadcaster(holdDID, 100, ":memory:")
|
||||
}
|
||||
|
||||
// Create storage driver from config (needed for bootstrap profile avatar)
|
||||
driver, err := factory.Create(ctx, cfg.Storage.Type(), cfg.Storage.Parameters())
|
||||
// Create S3 service (used for bootstrap, handlers, GC, etc.)
|
||||
s3Service, err = s3.NewS3Service(cfg.Storage.S3Params())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create storage driver: %w", err)
|
||||
return nil, fmt.Errorf("failed to create S3 service: %w", err)
|
||||
}
|
||||
|
||||
// Bootstrap PDS with captain record, hold owner as first crew member, and profile
|
||||
if err := s.PDS.Bootstrap(ctx, driver, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew, cfg.Registration.ProfileAvatarURL, cfg.Registration.Region); err != nil {
|
||||
if err := s.PDS.Bootstrap(ctx, s3Service, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew, cfg.Registration.ProfileAvatarURL, cfg.Registration.Region); err != nil {
|
||||
return nil, fmt.Errorf("failed to bootstrap PDS: %w", err)
|
||||
}
|
||||
|
||||
@@ -163,32 +161,21 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
|
||||
slog.Info("Quota enforcement disabled (no quota tiers configured)")
|
||||
}
|
||||
|
||||
// Create blob store adapter and XRPC handlers
|
||||
// Create XRPC handlers
|
||||
var ociHandler *oci.XRPCHandler
|
||||
if s.PDS != nil {
|
||||
ctx := context.Background()
|
||||
driver, err := factory.Create(ctx, cfg.Storage.Type(), cfg.Storage.Parameters())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create storage driver: %w", err)
|
||||
}
|
||||
|
||||
s3Service, err := s3.NewS3Service(cfg.Storage.Parameters())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create S3 service: %w", err)
|
||||
}
|
||||
|
||||
xrpcHandler = pds.NewXRPCHandler(s.PDS, *s3Service, driver, s.broadcaster, nil, s.QuotaManager)
|
||||
ociHandler = oci.NewXRPCHandler(s.PDS, *s3Service, driver, cfg.Registration.EnableBlueskyPosts, nil, s.QuotaManager)
|
||||
xrpcHandler = pds.NewXRPCHandler(s.PDS, *s3Service, s.broadcaster, nil, s.QuotaManager)
|
||||
ociHandler = oci.NewXRPCHandler(s.PDS, *s3Service, cfg.Registration.EnableBlueskyPosts, nil, s.QuotaManager)
|
||||
|
||||
// Initialize scan broadcaster if scanner secret is configured
|
||||
if cfg.Scanner.Secret != "" {
|
||||
holdDID := pds.GenerateDIDFromURL(cfg.Server.PublicURL)
|
||||
var sb *pds.ScanBroadcaster
|
||||
if s.holdDB != nil {
|
||||
sb, err = pds.NewScanBroadcasterWithDB(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, s.holdDB.DB, driver, s.PDS)
|
||||
sb, err = pds.NewScanBroadcasterWithDB(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, s.holdDB.DB, s3Service, s.PDS)
|
||||
} else {
|
||||
scanDBPath := cfg.Database.Path + "/db.sqlite3"
|
||||
sb, err = pds.NewScanBroadcaster(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, scanDBPath, driver, s.PDS)
|
||||
sb, err = pds.NewScanBroadcaster(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, scanDBPath, s3Service, s.PDS)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize scan broadcaster: %w", err)
|
||||
@@ -200,7 +187,7 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
|
||||
}
|
||||
|
||||
// Initialize garbage collector
|
||||
s.garbageCollector = gc.NewGarbageCollector(s.PDS, driver, cfg.GC)
|
||||
s.garbageCollector = gc.NewGarbageCollector(s.PDS, s3Service, cfg.GC)
|
||||
slog.Info("Garbage collector initialized",
|
||||
"enabled", cfg.GC.Enabled)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user