diff --git a/cmd/filemd.go b/cmd/filemd.go index 3a08f29..e6ab0c6 100644 --- a/cmd/filemd.go +++ b/cmd/filemd.go @@ -4,9 +4,13 @@ import ( "context" "fmt" + "github.com/amirhnajafiz/bedrock-api/internal/components/filemd" + "github.com/amirhnajafiz/bedrock-api/internal/components/logs" "github.com/amirhnajafiz/bedrock-api/internal/configs" + "github.com/amirhnajafiz/bedrock-api/internal/logger" "github.com/spf13/cobra" + "go.uber.org/zap" ) // FileMD represents the File Management Daemon command. @@ -32,5 +36,30 @@ func (f FileMD) Command() *cobra.Command { } func StartFileMD(ctx context.Context, cfg *configs.FileMDConfig) error { - return nil + logr := logger.New(cfg.LogLevel) + + apiBaseURL := fmt.Sprintf("http://%s:%d", cfg.APIHTTPHost, cfg.APIHTTPPort) + + scanner := &filemd.FSVolumeScanner{ + BasePath: cfg.VolumePath, + ExpectedFiles: logs.AllLogFiles, + } + + uploader := filemd.NewHTTPUploader(apiBaseURL) + + daemon := &filemd.Daemon{ + Scanner: scanner, + Uploader: uploader, + VolumePath: cfg.VolumePath, + PollInterval: cfg.PollInterval, + Logger: logr.Named("filemd"), + } + + logr.Info("starting filemd", + zap.String("volume_path", cfg.VolumePath), + zap.Duration("poll_interval", cfg.PollInterval), + zap.String("api_base_url", apiBaseURL), + ) + + return daemon.Run(ctx) } diff --git a/internal/components/filemd/filemd.go b/internal/components/filemd/filemd.go new file mode 100644 index 0000000..e63d903 --- /dev/null +++ b/internal/components/filemd/filemd.go @@ -0,0 +1,138 @@ +package filemd + +import ( + "context" + "time" + + "github.com/amirhnajafiz/bedrock-api/internal/components/logs" + "go.uber.org/zap" +) + +const ( + maxUploadRetries = 3 + initialRetryDelay = 1 * time.Second +) + +// uploadFields maps each well-known log file to the multipart form field name +// expected by the API endpoint. +var uploadFields = []struct { + Filename string + Field string +}{ + {Filename: logs.TargetLogFile, Field: "target_log"}, + {Filename: logs.TracerLogFile, Field: "tracer_log"}, + {Filename: logs.VFSPDFFile, Field: "vfs_pdf"}, +} + +// Daemon is the main FileMD orchestrator. It periodically scans for completed +// volumes, reads log files, uploads them to the API, and removes local artifacts. +type Daemon struct { + Scanner VolumeScanner + Uploader Uploader + VolumePath string + PollInterval time.Duration + Logger *zap.Logger +} + +// Run starts the FileMD polling loop. It blocks until ctx is cancelled. +func (d *Daemon) Run(ctx context.Context) error { + ticker := time.NewTicker(d.PollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + d.Logger.Info("filemd shutting down") + return ctx.Err() + case <-ticker.C: + d.processOnce() + } + } +} + +// processOnce performs a single scan-upload-cleanup cycle. +func (d *Daemon) processOnce() { + sessions, err := d.Scanner.ReadyVolumes() + if err != nil { + d.Logger.Warn("failed to scan volumes", zap.Error(err)) + return + } + + for _, sessionID := range sessions { + if err := d.processSession(sessionID); err != nil { + d.Logger.Warn("failed to process session", + zap.String("session_id", sessionID), + zap.Error(err), + ) + continue + } + + // Clean up local artifacts after successful upload. + if err := RemoveVolume(d.VolumePath, sessionID); err != nil { + d.Logger.Warn("failed to remove volume", + zap.String("session_id", sessionID), + zap.Error(err), + ) + } + + d.Logger.Info("processed session logs", zap.String("session_id", sessionID)) + } +} + +// processSession reads log files from the volume and uploads them to the API. +func (d *Daemon) processSession(sessionID string) error { + var uploads []LogUpload + + for _, mapping := range uploadFields { + data, err := ReadLogFile(d.VolumePath, sessionID, mapping.Filename) + if err != nil { + // Log file might not exist (e.g. tracer didn't produce output). + // We still upload whatever is available. + d.Logger.Debug("log file not found, skipping", + zap.String("session_id", sessionID), + zap.String("filename", mapping.Filename), + ) + continue + } + + uploads = append(uploads, LogUpload{ + Field: mapping.Field, + Filename: mapping.Filename, + Content: data, + }) + } + + if len(uploads) == 0 { + return nil + } + + return d.retryUpload(sessionID, uploads) +} + +// retryUpload attempts to upload session logs with exponential backoff. +// Returns the last error if all attempts fail. +func (d *Daemon) retryUpload(sessionID string, uploads []LogUpload) error { + var lastErr error + delay := initialRetryDelay + + for attempt := 1; attempt <= maxUploadRetries; attempt++ { + lastErr = d.Uploader.Upload(sessionID, uploads) + if lastErr == nil { + return nil + } + + d.Logger.Warn("upload attempt failed", + zap.String("session_id", sessionID), + zap.Int("attempt", attempt), + zap.Int("max_attempts", maxUploadRetries), + zap.Error(lastErr), + ) + + if attempt < maxUploadRetries { + time.Sleep(delay) + delay *= 2 + } + } + + return lastErr +} diff --git a/internal/components/filemd/filemd_test.go b/internal/components/filemd/filemd_test.go new file mode 100644 index 0000000..2bacdaa --- /dev/null +++ b/internal/components/filemd/filemd_test.go @@ -0,0 +1,274 @@ +package filemd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +// mockScanner implements VolumeScanner for testing. +type mockScanner struct { + sessions []string + err error +} + +func (m *mockScanner) ReadyVolumes() ([]string, error) { + return m.sessions, m.err +} + +// recordingUploader records uploads for verification. +type recordingUploader struct { + mu sync.Mutex + uploads map[string][]LogUpload + err error +} + +func newRecordingUploader() *recordingUploader { + return &recordingUploader{uploads: make(map[string][]LogUpload)} +} + +func (r *recordingUploader) Upload(sessionID string, files []LogUpload) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.err != nil { + return r.err + } + r.uploads[sessionID] = files + return nil +} + +func (r *recordingUploader) get(sessionID string) []LogUpload { + r.mu.Lock() + defer r.mu.Unlock() + return r.uploads[sessionID] +} + +func TestDaemon_ProcessSession(t *testing.T) { + base := t.TempDir() + + // Set up a ready volume with log files. + sessionDir := filepath.Join(base, "sess-1") + os.MkdirAll(sessionDir, 0o755) + os.WriteFile(filepath.Join(sessionDir, "target.log"), []byte("target-data"), 0o644) + os.WriteFile(filepath.Join(sessionDir, "tracer.log"), []byte("tracer-data"), 0o644) + os.WriteFile(filepath.Join(sessionDir, "vfs.pdf"), []byte("pdf-data"), 0o644) + + uploader := newRecordingUploader() + + d := &Daemon{ + Scanner: &mockScanner{sessions: []string{"sess-1"}}, + Uploader: uploader, + VolumePath: base, + PollInterval: time.Millisecond, + Logger: noopLogger(), + } + + d.processOnce() + + uploads := uploader.get("sess-1") + if len(uploads) != 3 { + t.Fatalf("expected 3 uploads, got %d", len(uploads)) + } + + want := map[string]string{ + "target_log": "target-data", + "tracer_log": "tracer-data", + "vfs_pdf": "pdf-data", + } + for _, u := range uploads { + if want[u.Field] != string(u.Content) { + t.Errorf("upload field %s: got %q, want %q", u.Field, u.Content, want[u.Field]) + } + } + + // Verify volume was cleaned up. + if _, err := os.Stat(sessionDir); !os.IsNotExist(err) { + t.Error("volume directory should have been removed after upload") + } +} + +func TestDaemon_ProcessSession_PartialFiles(t *testing.T) { + base := t.TempDir() + + sessionDir := filepath.Join(base, "sess-2") + os.MkdirAll(sessionDir, 0o755) + os.WriteFile(filepath.Join(sessionDir, "target.log"), []byte("only-target"), 0o644) + + uploader := newRecordingUploader() + + d := &Daemon{ + Scanner: &mockScanner{sessions: []string{"sess-2"}}, + Uploader: uploader, + VolumePath: base, + PollInterval: time.Millisecond, + Logger: noopLogger(), + } + + d.processOnce() + + uploads := uploader.get("sess-2") + if len(uploads) != 1 { + t.Fatalf("expected 1 upload, got %d", len(uploads)) + } + + if uploads[0].Field != "target_log" || string(uploads[0].Content) != "only-target" { + t.Errorf("unexpected upload: %+v", uploads[0]) + } +} + +func TestDaemon_ProcessSession_NoFiles(t *testing.T) { + base := t.TempDir() + + sessionDir := filepath.Join(base, "sess-3") + os.MkdirAll(sessionDir, 0o755) + + uploader := newRecordingUploader() + + d := &Daemon{ + Scanner: &mockScanner{sessions: []string{"sess-3"}}, + Uploader: uploader, + VolumePath: base, + PollInterval: time.Millisecond, + Logger: noopLogger(), + } + + d.processOnce() + + uploads := uploader.get("sess-3") + if len(uploads) != 0 { + t.Errorf("expected 0 uploads for empty volume, got %d", len(uploads)) + } +} + +// failingUploader fails a configurable number of times before succeeding. +type failingUploader struct { + mu sync.Mutex + failCount int + callCount int + uploads map[string][]LogUpload +} + +func newFailingUploader(failCount int) *failingUploader { + return &failingUploader{ + failCount: failCount, + uploads: make(map[string][]LogUpload), + } +} + +func (f *failingUploader) Upload(sessionID string, files []LogUpload) error { + f.mu.Lock() + defer f.mu.Unlock() + f.callCount++ + if f.callCount <= f.failCount { + return fmt.Errorf("upload error (attempt %d)", f.callCount) + } + f.uploads[sessionID] = files + return nil +} + +func (f *failingUploader) getCallCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.callCount +} + +func (f *failingUploader) get(sessionID string) []LogUpload { + f.mu.Lock() + defer f.mu.Unlock() + return f.uploads[sessionID] +} + +func TestDaemon_ProcessSession_RetrySuccess(t *testing.T) { + base := t.TempDir() + + sessionDir := filepath.Join(base, "sess-retry") + os.MkdirAll(sessionDir, 0o755) + os.WriteFile(filepath.Join(sessionDir, "target.log"), []byte("target-data"), 0o644) + + // Fail twice, succeed on third attempt. + uploader := newFailingUploader(2) + + d := &Daemon{ + Scanner: &mockScanner{sessions: []string{"sess-retry"}}, + Uploader: uploader, + VolumePath: base, + PollInterval: time.Millisecond, + Logger: noopLogger(), + } + + d.processOnce() + + if got := uploader.getCallCount(); got != 3 { + t.Errorf("expected 3 upload attempts, got %d", got) + } + + uploads := uploader.get("sess-retry") + if len(uploads) != 1 { + t.Fatalf("expected 1 upload after retry, got %d", len(uploads)) + } + + if _, err := os.Stat(sessionDir); !os.IsNotExist(err) { + t.Error("volume directory should have been removed after successful retry") + } +} + +func TestDaemon_ProcessSession_RetryExhaustion(t *testing.T) { + base := t.TempDir() + + sessionDir := filepath.Join(base, "sess-fail") + os.MkdirAll(sessionDir, 0o755) + os.WriteFile(filepath.Join(sessionDir, "target.log"), []byte("target-data"), 0o644) + + // Fail all 3 attempts. + uploader := newFailingUploader(3) + + d := &Daemon{ + Scanner: &mockScanner{sessions: []string{"sess-fail"}}, + Uploader: uploader, + VolumePath: base, + PollInterval: time.Millisecond, + Logger: noopLogger(), + } + + d.processOnce() + + if got := uploader.getCallCount(); got != 3 { + t.Errorf("expected 3 upload attempts, got %d", got) + } + + if _, err := os.Stat(sessionDir); os.IsNotExist(err) { + t.Error("volume directory should NOT have been removed after retry exhaustion") + } +} + +func TestDaemon_RunCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + d := &Daemon{ + Scanner: &mockScanner{}, + Uploader: newRecordingUploader(), + VolumePath: t.TempDir(), + PollInterval: time.Second, + Logger: noopLogger(), + } + + done := make(chan error, 1) + go func() { + done <- d.Run(ctx) + }() + + cancel() + + select { + case err := <-done: + if err != context.Canceled { + t.Errorf("Run: got %v, want context.Canceled", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Run did not exit after context cancellation") + } +} diff --git a/internal/components/filemd/testutil_test.go b/internal/components/filemd/testutil_test.go new file mode 100644 index 0000000..afcec51 --- /dev/null +++ b/internal/components/filemd/testutil_test.go @@ -0,0 +1,7 @@ +package filemd + +import "go.uber.org/zap" + +func noopLogger() *zap.Logger { + return zap.NewNop() +} diff --git a/internal/components/filemd/uploader.go b/internal/components/filemd/uploader.go new file mode 100644 index 0000000..1695f70 --- /dev/null +++ b/internal/components/filemd/uploader.go @@ -0,0 +1,74 @@ +package filemd + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" +) + +// LogUpload pairs a multipart form field name with the file content to upload. +type LogUpload struct { + Field string + Filename string + Content []byte +} + +// Uploader sends log files to the API's POST /api/sessions/:id/logs endpoint. +type Uploader interface { + Upload(sessionID string, files []LogUpload) error +} + +// HTTPUploader implements Uploader using standard net/http. +type HTTPUploader struct { + BaseURL string // e.g. "http://127.0.0.1:8080" + Client *http.Client +} + +// NewHTTPUploader creates an Uploader targeting the given API base URL. +func NewHTTPUploader(baseURL string) Uploader { + return &HTTPUploader{ + BaseURL: baseURL, + Client: &http.Client{}, + } +} + +func (u *HTTPUploader) Upload(sessionID string, files []LogUpload) error { + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + for _, f := range files { + part, err := writer.CreateFormFile(f.Field, f.Filename) + if err != nil { + return fmt.Errorf("create form file %s: %w", f.Field, err) + } + if _, err := part.Write(f.Content); err != nil { + return fmt.Errorf("write form file %s: %w", f.Field, err) + } + } + + if err := writer.Close(); err != nil { + return fmt.Errorf("close multipart writer: %w", err) + } + + url := fmt.Sprintf("%s/api/sessions/%s/logs", u.BaseURL, sessionID) + req, err := http.NewRequest(http.MethodPost, url, &buf) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := u.Client.Do(req) + if err != nil { + return fmt.Errorf("upload request: %w", err) + } + defer resp.Body.Close() + io.Copy(io.Discard, resp.Body) + + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("upload failed: status %d", resp.StatusCode) + } + + return nil +} diff --git a/internal/components/filemd/uploader_test.go b/internal/components/filemd/uploader_test.go new file mode 100644 index 0000000..e9eb8c8 --- /dev/null +++ b/internal/components/filemd/uploader_test.go @@ -0,0 +1,84 @@ +package filemd + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHTTPUploader_Upload(t *testing.T) { + var receivedContentType string + var receivedBody []byte + var receivedPath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedContentType = r.Header.Get("Content-Type") + receivedPath = r.URL.Path + receivedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + })) + defer server.Close() + + uploader := &HTTPUploader{ + BaseURL: server.URL, + Client: server.Client(), + } + + files := []LogUpload{ + {Field: "target_log", Filename: "target.log", Content: []byte("target")}, + {Field: "tracer_log", Filename: "tracer.log", Content: []byte("tracer")}, + } + + err := uploader.Upload("sess-1", files) + if err != nil { + t.Fatalf("Upload: %v", err) + } + + if receivedPath != "/api/sessions/sess-1/logs" { + t.Errorf("Upload path: got %q, want %q", receivedPath, "/api/sessions/sess-1/logs") + } + + if receivedContentType == "" { + t.Error("Upload: missing Content-Type header") + } + + if len(receivedBody) == 0 { + t.Error("Upload: empty body") + } +} + +func TestHTTPUploader_Upload_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + uploader := &HTTPUploader{ + BaseURL: server.URL, + Client: server.Client(), + } + + err := uploader.Upload("sess-1", []LogUpload{ + {Field: "target_log", Filename: "target.log", Content: []byte("data")}, + }) + + if err == nil { + t.Error("Upload with server error: expected error, got nil") + } +} + +func TestHTTPUploader_Upload_ConnectionError(t *testing.T) { + uploader := &HTTPUploader{ + BaseURL: "http://127.0.0.1:0", // nothing listening + Client: &http.Client{}, + } + + err := uploader.Upload("sess-1", []LogUpload{ + {Field: "target_log", Filename: "target.log", Content: []byte("data")}, + }) + + if err == nil { + t.Error("Upload with connection error: expected error, got nil") + } +} diff --git a/internal/components/filemd/watcher.go b/internal/components/filemd/watcher.go new file mode 100644 index 0000000..f52c248 --- /dev/null +++ b/internal/components/filemd/watcher.go @@ -0,0 +1,73 @@ +package filemd + +import ( + "os" + "path/filepath" +) + +// LockFileName is the sentinel file that DockerD places inside a volume while +// containers are still running. Its absence signals that the volume is ready +// for log collection. +const LockFileName = ".lock" + +// VolumeScanner abstracts filesystem scanning so the watcher can be tested +// without real directories. +type VolumeScanner interface { + // ReadyVolumes returns session IDs whose volumes are unlocked and contain + // at least one log file. + ReadyVolumes() ([]string, error) +} + +// FSVolumeScanner scans a base directory for session volume subdirectories. +// Each subdirectory is named after a session ID. A volume is considered ready +// when no .lock file is present and at least one expected log file exists. +type FSVolumeScanner struct { + BasePath string + ExpectedFiles []string +} + +// ReadyVolumes walks BasePath looking for directories that are unlocked. +func (s *FSVolumeScanner) ReadyVolumes() ([]string, error) { + entries, err := os.ReadDir(s.BasePath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var ready []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + sessionID := entry.Name() + volPath := filepath.Join(s.BasePath, sessionID) + + // Skip if still locked. + if _, err := os.Stat(filepath.Join(volPath, LockFileName)); err == nil { + continue + } + + // Check that at least one expected file exists. + for _, f := range s.ExpectedFiles { + if _, err := os.Stat(filepath.Join(volPath, f)); err == nil { + ready = append(ready, sessionID) + break + } + } + } + + return ready, nil +} + +// ReadLogFile reads the contents of a log file from a volume directory. +func ReadLogFile(basePath, sessionID, filename string) ([]byte, error) { + return os.ReadFile(filepath.Join(basePath, sessionID, filename)) +} + +// RemoveVolume deletes a session's volume directory and all its contents. +func RemoveVolume(basePath, sessionID string) error { + return os.RemoveAll(filepath.Join(basePath, sessionID)) +} diff --git a/internal/components/filemd/watcher_test.go b/internal/components/filemd/watcher_test.go new file mode 100644 index 0000000..761ebb9 --- /dev/null +++ b/internal/components/filemd/watcher_test.go @@ -0,0 +1,104 @@ +package filemd + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFSVolumeScanner_ReadyVolumes(t *testing.T) { + base := t.TempDir() + + // Create a locked volume (should be skipped). + locked := filepath.Join(base, "sess-locked") + os.MkdirAll(locked, 0o755) + os.WriteFile(filepath.Join(locked, LockFileName), nil, 0o644) + os.WriteFile(filepath.Join(locked, "target.log"), []byte("data"), 0o644) + + // Create an unlocked volume with log files (should be detected). + ready := filepath.Join(base, "sess-ready") + os.MkdirAll(ready, 0o755) + os.WriteFile(filepath.Join(ready, "target.log"), []byte("data"), 0o644) + os.WriteFile(filepath.Join(ready, "tracer.log"), []byte("data"), 0o644) + + // Create an unlocked volume with no log files (should be skipped). + empty := filepath.Join(base, "sess-empty") + os.MkdirAll(empty, 0o755) + + // Create a regular file in base (not a directory, should be skipped). + os.WriteFile(filepath.Join(base, "stray-file"), []byte("x"), 0o644) + + scanner := &FSVolumeScanner{ + BasePath: base, + ExpectedFiles: []string{"target.log", "tracer.log", "vfs.pdf"}, + } + + sessions, err := scanner.ReadyVolumes() + if err != nil { + t.Fatalf("ReadyVolumes: %v", err) + } + + if len(sessions) != 1 { + t.Fatalf("ReadyVolumes: got %d sessions, want 1; sessions=%v", len(sessions), sessions) + } + + if sessions[0] != "sess-ready" { + t.Errorf("ReadyVolumes: got %q, want %q", sessions[0], "sess-ready") + } +} + +func TestFSVolumeScanner_NonexistentBase(t *testing.T) { + scanner := &FSVolumeScanner{ + BasePath: filepath.Join(t.TempDir(), "does-not-exist"), + ExpectedFiles: []string{"target.log"}, + } + + sessions, err := scanner.ReadyVolumes() + if err != nil { + t.Fatalf("ReadyVolumes on missing dir: %v", err) + } + + if len(sessions) != 0 { + t.Errorf("ReadyVolumes on missing dir: got %d, want 0", len(sessions)) + } +} + +func TestReadLogFile(t *testing.T) { + base := t.TempDir() + sessionDir := filepath.Join(base, "sess-1") + os.MkdirAll(sessionDir, 0o755) + os.WriteFile(filepath.Join(sessionDir, "target.log"), []byte("hello"), 0o644) + + data, err := ReadLogFile(base, "sess-1", "target.log") + if err != nil { + t.Fatalf("ReadLogFile: %v", err) + } + + if string(data) != "hello" { + t.Errorf("ReadLogFile: got %q, want %q", data, "hello") + } +} + +func TestReadLogFile_NotFound(t *testing.T) { + base := t.TempDir() + + _, err := ReadLogFile(base, "no-session", "target.log") + if err == nil { + t.Error("ReadLogFile: expected error for missing file") + } +} + +func TestRemoveVolume(t *testing.T) { + base := t.TempDir() + sessionDir := filepath.Join(base, "sess-1") + os.MkdirAll(sessionDir, 0o755) + os.WriteFile(filepath.Join(sessionDir, "target.log"), []byte("data"), 0o644) + + if err := RemoveVolume(base, "sess-1"); err != nil { + t.Fatalf("RemoveVolume: %v", err) + } + + if _, err := os.Stat(sessionDir); !os.IsNotExist(err) { + t.Error("RemoveVolume: directory still exists") + } +} diff --git a/internal/components/logs/logs.go b/internal/components/logs/logs.go new file mode 100644 index 0000000..37e3c83 --- /dev/null +++ b/internal/components/logs/logs.go @@ -0,0 +1,39 @@ +package logs + +import "github.com/amirhnajafiz/bedrock-api/internal/storage" + +// Well-known log file names produced by each tracing session. +const ( + TargetLogFile = "target.log" + TracerLogFile = "tracer.log" + VFSPDFFile = "vfs.pdf" +) + +// AllLogFiles lists every file that FileMD is expected to upload per session. +var AllLogFiles = []string{TargetLogFile, TracerLogFile, VFSPDFFile} + +// LogEntry represents a single log file associated with a session. +type LogEntry struct { + Filename string `json:"filename"` + Content []byte `json:"content"` +} + +// LogStore provides domain-specific access to session log files. +type LogStore interface { + // SaveLog persists a single log file under the given session ID. + SaveLog(sessionID, filename string, data []byte) error + + // GetLog retrieves a single log file for the given session ID. + GetLog(sessionID, filename string) ([]byte, error) + + // ListLogs returns all log entries stored for the given session ID. + ListLogs(sessionID string) ([]LogEntry, error) + + // DeleteLogs removes all log files for the given session ID. + DeleteLogs(sessionID string) error +} + +// NewLogStore returns a LogStore backed by the provided KVStorage. +func NewLogStore(backend storage.KVStorage) LogStore { + return &logStore{backend: backend} +} diff --git a/internal/components/logs/store.go b/internal/components/logs/store.go new file mode 100644 index 0000000..d9cacce --- /dev/null +++ b/internal/components/logs/store.go @@ -0,0 +1,61 @@ +package logs + +import ( + "errors" + "path" + + "github.com/amirhnajafiz/bedrock-api/internal/storage" + "github.com/amirhnajafiz/bedrock-api/pkg/xerrors" +) + +// logPrefix namespaces all log keys inside the shared KVStorage backend. +// Keys follow the pattern: logs// +const logPrefix = "logs/" + +// logStore wraps any storage.KVStorage backend and exposes log-specific operations. +type logStore struct { + backend storage.KVStorage +} + +// logKey builds the namespaced key for a log file. +func logKey(sessionID, filename string) string { + return path.Join(logPrefix+sessionID, filename) +} + +// sessionPrefix returns the key prefix for all logs of a session. +func sessionLogPrefix(sessionID string) string { + return logPrefix + sessionID + "/" +} + +func (s *logStore) SaveLog(sessionID, filename string, data []byte) error { + return s.backend.Set(logKey(sessionID, filename), data) +} + +func (s *logStore) GetLog(sessionID, filename string) ([]byte, error) { + return s.backend.Get(logKey(sessionID, filename)) +} + +func (s *logStore) ListLogs(sessionID string) ([]LogEntry, error) { + // Retrieve each well-known file individually so we preserve filenames. + var entries []LogEntry + for _, name := range AllLogFiles { + data, err := s.backend.Get(logKey(sessionID, name)) + if err != nil { + if errors.Is(err, xerrors.StorageErrNotFound) { + continue + } + return nil, err + } + entries = append(entries, LogEntry{Filename: name, Content: data}) + } + return entries, nil +} + +func (s *logStore) DeleteLogs(sessionID string) error { + for _, name := range AllLogFiles { + if err := s.backend.Delete(logKey(sessionID, name)); err != nil { + return err + } + } + return nil +} diff --git a/internal/components/logs/store_test.go b/internal/components/logs/store_test.go new file mode 100644 index 0000000..4053410 --- /dev/null +++ b/internal/components/logs/store_test.go @@ -0,0 +1,155 @@ +package logs + +import ( + "errors" + "testing" + "time" + + "github.com/amirhnajafiz/bedrock-api/internal/storage/gocache" + "github.com/amirhnajafiz/bedrock-api/pkg/xerrors" +) + +func newTestLogStore() LogStore { + return &logStore{backend: gocache.NewBackend(time.Minute)} +} + +func TestLogStore_SaveAndGet(t *testing.T) { + s := newTestLogStore() + + content := []byte("target container output") + if err := s.SaveLog("sess-1", TargetLogFile, content); err != nil { + t.Fatalf("SaveLog: %v", err) + } + + got, err := s.GetLog("sess-1", TargetLogFile) + if err != nil { + t.Fatalf("GetLog: %v", err) + } + + if string(got) != string(content) { + t.Errorf("GetLog: got %q, want %q", got, content) + } +} + +func TestLogStore_GetNotFound(t *testing.T) { + s := newTestLogStore() + + _, err := s.GetLog("nonexistent", TargetLogFile) + if !errors.Is(err, xerrors.StorageErrNotFound) { + t.Errorf("GetLog missing: got %v, want StorageErrNotFound", err) + } +} + +func TestLogStore_SaveOverwrite(t *testing.T) { + s := newTestLogStore() + + _ = s.SaveLog("sess-1", TargetLogFile, []byte("old")) + _ = s.SaveLog("sess-1", TargetLogFile, []byte("new")) + + got, err := s.GetLog("sess-1", TargetLogFile) + if err != nil { + t.Fatalf("GetLog: %v", err) + } + + if string(got) != "new" { + t.Errorf("GetLog after overwrite: got %q, want %q", got, "new") + } +} + +func TestLogStore_ListLogs(t *testing.T) { + s := newTestLogStore() + + _ = s.SaveLog("sess-1", TargetLogFile, []byte("target")) + _ = s.SaveLog("sess-1", TracerLogFile, []byte("tracer")) + _ = s.SaveLog("sess-1", VFSPDFFile, []byte("pdf")) + + entries, err := s.ListLogs("sess-1") + if err != nil { + t.Fatalf("ListLogs: %v", err) + } + + if len(entries) != 3 { + t.Fatalf("ListLogs: got %d entries, want 3", len(entries)) + } + + want := map[string]string{ + TargetLogFile: "target", + TracerLogFile: "tracer", + VFSPDFFile: "pdf", + } + for _, e := range entries { + if want[e.Filename] != string(e.Content) { + t.Errorf("ListLogs %s: got %q, want %q", e.Filename, e.Content, want[e.Filename]) + } + } +} + +func TestLogStore_ListLogs_Partial(t *testing.T) { + s := newTestLogStore() + + _ = s.SaveLog("sess-1", TargetLogFile, []byte("target only")) + + entries, err := s.ListLogs("sess-1") + if err != nil { + t.Fatalf("ListLogs: %v", err) + } + + if len(entries) != 1 { + t.Fatalf("ListLogs partial: got %d entries, want 1", len(entries)) + } + + if entries[0].Filename != TargetLogFile { + t.Errorf("ListLogs partial: got filename %q, want %q", entries[0].Filename, TargetLogFile) + } +} + +func TestLogStore_ListLogs_Empty(t *testing.T) { + s := newTestLogStore() + + entries, err := s.ListLogs("nonexistent") + if err != nil { + t.Fatalf("ListLogs empty: %v", err) + } + + if len(entries) != 0 { + t.Errorf("ListLogs empty: got %d entries, want 0", len(entries)) + } +} + +func TestLogStore_DeleteLogs(t *testing.T) { + s := newTestLogStore() + + _ = s.SaveLog("sess-1", TargetLogFile, []byte("target")) + _ = s.SaveLog("sess-1", TracerLogFile, []byte("tracer")) + _ = s.SaveLog("sess-1", VFSPDFFile, []byte("pdf")) + + if err := s.DeleteLogs("sess-1"); err != nil { + t.Fatalf("DeleteLogs: %v", err) + } + + entries, err := s.ListLogs("sess-1") + if err != nil { + t.Fatalf("ListLogs after delete: %v", err) + } + + if len(entries) != 0 { + t.Errorf("ListLogs after delete: got %d entries, want 0", len(entries)) + } +} + +func TestLogStore_IsolationBetweenSessions(t *testing.T) { + s := newTestLogStore() + + _ = s.SaveLog("sess-1", TargetLogFile, []byte("s1-target")) + _ = s.SaveLog("sess-2", TargetLogFile, []byte("s2-target")) + + got1, _ := s.GetLog("sess-1", TargetLogFile) + got2, _ := s.GetLog("sess-2", TargetLogFile) + + if string(got1) != "s1-target" { + t.Errorf("session isolation: sess-1 got %q", got1) + } + if string(got2) != "s2-target" { + t.Errorf("session isolation: sess-2 got %q", got2) + } +} diff --git a/internal/configs/configs.go b/internal/configs/configs.go index 0132a2f..e6fe49e 100644 --- a/internal/configs/configs.go +++ b/internal/configs/configs.go @@ -41,10 +41,12 @@ type DockerdConfig struct { // FileMDConfig represents the configuration for the File Management Daemon. type FileMDConfig struct { - LogLevel string `koanf:"log_level" validate:"oneof=debug info warn error"` - APIHTTPHost string `koanf:"api_http_host" validate:"ip"` - APIHTTPPort int `koanf:"api_http_port" validate:"min=1,max=65535"` - DataDir string `koanf:"data_dir"` + LogLevel string `koanf:"log_level" validate:"oneof=debug info warn error"` + APIHTTPHost string `koanf:"api_http_host" validate:"ip"` + APIHTTPPort int `koanf:"api_http_port" validate:"min=1,max=65535"` + DataDir string `koanf:"data_dir"` + VolumePath string `koanf:"volume_path"` + PollInterval time.Duration `koanf:"poll_interval" validate:"duration"` } // Config represents the configuration for the application. diff --git a/internal/configs/default.go b/internal/configs/default.go index 186bc4a..321a2ef 100644 --- a/internal/configs/default.go +++ b/internal/configs/default.go @@ -1,5 +1,7 @@ package configs +import "time" + // Default returns the default configuration for the application. func Default() *Config { return &Config{ @@ -40,9 +42,11 @@ func DefaultDockerdConfig() *DockerdConfig { func DefaultFileMDConfig() *FileMDConfig { return &FileMDConfig{ - LogLevel: "info", - APIHTTPHost: "127.0.0.1", - APIHTTPPort: 8081, - DataDir: "/tmp/bedrock-logs", + LogLevel: "info", + APIHTTPHost: "127.0.0.1", + APIHTTPPort: 8081, + DataDir: "/tmp/bedrock-logs", + VolumePath: "/var/lib/bedrock/volumes", + PollInterval: 10 * time.Second, } } diff --git a/internal/ports/daemon/handlers.go b/internal/ports/daemon/handlers.go index b0bc830..8253fa4 100644 --- a/internal/ports/daemon/handlers.go +++ b/internal/ports/daemon/handlers.go @@ -63,6 +63,9 @@ func (d Daemon) prepareEvents() ([]models.Event, error) { } else { status = enums.EventTypeSessionFailed } + + // collect logs and remove lock file for FileMD + d.collectSessionLogs(sid) } // append the event to the list of events @@ -170,6 +173,11 @@ func (d Daemon) startContainersForSession(sessionId string, sessionSpec models.S return fmt.Errorf("failed to create tracer output directory: %w", err) } + // create lock file to signal volume is not ready + if err := createLockFile(d.datadir, sessionId); err != nil { + return fmt.Errorf("failed to create lock file: %w", err) + } + // create the tracer container tracerId, err := d.ContainerManager.Create( ctx, @@ -229,6 +237,66 @@ func (d Daemon) startContainersForSession(sessionId string, sessionSpec models.S return nil } +// collectSessionLogs fetches stdout/stderr from the target and tracer containers +// and writes them to the session volume. It then removes the .lock file to signal +// that the volume is ready for FileMD to upload. +func (d Daemon) collectSessionLogs(sessionId string) { + ctx := context.Background() + + targetName := fmt.Sprintf("bedrock-target-%s", sessionId) + tracerName := fmt.Sprintf("bedrock-tracer-%s", sessionId) + + // list containers for this session to resolve IDs from names + containers, err := d.ContainerManager.List(ctx, map[string]string{ + daemonContainerKey: daemonContainerVal, + daemonContainerSessionId: sessionId, + }) + if err != nil { + d.Logr.Warn("failed to list containers for log collection", zap.String("session_id", sessionId), zap.Error(err)) + if err := removeLockFile(d.datadir, sessionId); err != nil { + d.Logr.Warn("failed to remove lock file", zap.String("session_id", sessionId), zap.Error(err)) + } + return + } + + // resolve container IDs by name + var targetID, tracerID string + for _, c := range containers { + if c.Name == targetName { + targetID = c.ID + } else if c.Name == tracerName { + tracerID = c.ID + } + } + + // collect target container logs + if targetID != "" { + targetLogPath := fmt.Sprintf("%s/%s/target.log", d.datadir, sessionId) + if err := d.ContainerManager.StoreLogs(ctx, targetID, targetLogPath); err != nil { + d.Logr.Warn("failed to store target logs", zap.String("session_id", sessionId), zap.Error(err)) + } + } else { + d.Logr.Warn("target container not found for log collection", zap.String("session_id", sessionId)) + } + + // collect tracer container logs + if tracerID != "" { + tracerLogPath := fmt.Sprintf("%s/%s/tracer.log", d.datadir, sessionId) + if err := d.ContainerManager.StoreLogs(ctx, tracerID, tracerLogPath); err != nil { + d.Logr.Warn("failed to store tracer logs", zap.String("session_id", sessionId), zap.Error(err)) + } + } else { + d.Logr.Warn("tracer container not found for log collection", zap.String("session_id", sessionId)) + } + + // remove lock file to signal volume is ready + if err := removeLockFile(d.datadir, sessionId); err != nil { + d.Logr.Warn("failed to remove lock file", zap.String("session_id", sessionId), zap.Error(err)) + } + + d.Logr.Info("collected session logs", zap.String("session_id", sessionId)) +} + // stops the target and tracer containers for a given session. func (d Daemon) stopContainersForSession(sessionId string, remove bool) error { // create a new background context for stopping containers diff --git a/internal/ports/daemon/handlers_test.go b/internal/ports/daemon/handlers_test.go new file mode 100644 index 0000000..e35917e --- /dev/null +++ b/internal/ports/daemon/handlers_test.go @@ -0,0 +1,154 @@ +package daemon + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/amirhnajafiz/bedrock-api/pkg/models" + "go.uber.org/zap" +) + +// stubContainerManager records calls to ContainerManager methods. +type stubContainerManager struct { + createIDs map[string]string + listResult []*models.ContainerInfo + getResult map[string]*models.ContainerInfo + storeLogsCalls []storeLogsCall +} + +type storeLogsCall struct { + ContainerID string + FilePath string +} + +func newStubContainerManager() *stubContainerManager { + return &stubContainerManager{ + createIDs: make(map[string]string), + getResult: make(map[string]*models.ContainerInfo), + } +} + +func (s *stubContainerManager) Create(_ context.Context, cfg *models.ContainerConfig) (string, error) { + id := "id-" + cfg.Name + s.createIDs[cfg.Name] = id + return id, nil +} + +func (s *stubContainerManager) Start(_ context.Context, _ string) error { return nil } +func (s *stubContainerManager) Stop(_ context.Context, _ string) error { return nil } +func (s *stubContainerManager) Remove(_ context.Context, _ string) error { return nil } + +func (s *stubContainerManager) StoreLogs(_ context.Context, containerID string, filePath string) error { + s.storeLogsCalls = append(s.storeLogsCalls, storeLogsCall{containerID, filePath}) + return os.WriteFile(filePath, []byte("log-data-"+containerID), 0644) +} + +func (s *stubContainerManager) List(_ context.Context, _ map[string]string) ([]*models.ContainerInfo, error) { + return s.listResult, nil +} + +func (s *stubContainerManager) Get(_ context.Context, id string) (*models.ContainerInfo, error) { + return s.getResult[id], nil +} + +func TestStartContainersForSession_CreatesLockFile(t *testing.T) { + datadir := t.TempDir() + cm := newStubContainerManager() + + d := Daemon{ + ContainerManager: cm, + Logr: zap.NewNop(), + datadir: datadir, + tracerImage: "tracer:latest", + } + + err := d.startContainersForSession("sess-1", models.Spec{ + Image: "ubuntu:latest", + Command: "echo hello", + }) + if err != nil { + t.Fatalf("startContainersForSession: %v", err) + } + + lockPath := filepath.Join(datadir, "sess-1", ".lock") + if _, err := os.Stat(lockPath); os.IsNotExist(err) { + t.Error("expected .lock file to be created, but it does not exist") + } +} + +func TestCollectSessionLogs_StoresLogsAndRemovesLock(t *testing.T) { + datadir := t.TempDir() + sessionID := "sess-1" + + sessionDir := filepath.Join(datadir, sessionID) + os.MkdirAll(sessionDir, 0o755) + os.WriteFile(filepath.Join(sessionDir, ".lock"), nil, 0o644) + + cm := newStubContainerManager() + cm.listResult = []*models.ContainerInfo{ + {ID: "id-target", Name: "bedrock-target-sess-1", Labels: map[string]string{ + daemonContainerKey: daemonContainerVal, + daemonContainerType: daemonContainerTypeTarget, + daemonContainerSessionId: sessionID, + }}, + {ID: "id-tracer", Name: "bedrock-tracer-sess-1", Labels: map[string]string{ + daemonContainerKey: daemonContainerVal, + daemonContainerType: daemonContainerTypeTracer, + daemonContainerSessionId: sessionID, + }}, + } + + d := Daemon{ + ContainerManager: cm, + Logr: zap.NewNop(), + datadir: datadir, + } + + d.collectSessionLogs(sessionID) + + if len(cm.storeLogsCalls) != 2 { + t.Fatalf("expected 2 StoreLogs calls, got %d", len(cm.storeLogsCalls)) + } + + targetLog := filepath.Join(sessionDir, "target.log") + if _, err := os.Stat(targetLog); os.IsNotExist(err) { + t.Error("expected target.log to be created") + } + + tracerLog := filepath.Join(sessionDir, "tracer.log") + if _, err := os.Stat(tracerLog); os.IsNotExist(err) { + t.Error("expected tracer.log to be created") + } + + lockPath := filepath.Join(sessionDir, ".lock") + if _, err := os.Stat(lockPath); !os.IsNotExist(err) { + t.Error("expected .lock file to be removed after log collection") + } +} + +func TestCollectSessionLogs_RemovesLockEvenOnFailure(t *testing.T) { + datadir := t.TempDir() + sessionID := "sess-2" + + sessionDir := filepath.Join(datadir, sessionID) + os.MkdirAll(sessionDir, 0o755) + os.WriteFile(filepath.Join(sessionDir, ".lock"), nil, 0o644) + + cm := newStubContainerManager() + cm.listResult = []*models.ContainerInfo{} + + d := Daemon{ + ContainerManager: cm, + Logr: zap.NewNop(), + datadir: datadir, + } + + d.collectSessionLogs(sessionID) + + lockPath := filepath.Join(sessionDir, ".lock") + if _, err := os.Stat(lockPath); !os.IsNotExist(err) { + t.Error("expected .lock file to be removed even when no containers found") + } +} diff --git a/internal/ports/daemon/utils.go b/internal/ports/daemon/utils.go index d612a5e..4d04c1e 100644 --- a/internal/ports/daemon/utils.go +++ b/internal/ports/daemon/utils.go @@ -5,6 +5,21 @@ import ( "os" ) +// returns the path to the lock file for a session volume. +func lockFilePath(base, sessionId string) string { + return fmt.Sprintf("%s/%s/.lock", base, sessionId) +} + +// creates a .lock file in the session volume directory. +func createLockFile(base, sessionId string) error { + return os.WriteFile(lockFilePath(base, sessionId), nil, 0644) +} + +// removes the .lock file from the session volume directory. +func removeLockFile(base, sessionId string) error { + return os.Remove(lockFilePath(base, sessionId)) +} + // creates the output directory for the tracer if it doesn't exist. func createTracerOutputDir(base, sessionId string) error { outputDir := fmt.Sprintf("%s/%s", base, sessionId) diff --git a/internal/ports/http/handlers.go b/internal/ports/http/handlers.go index e61a750..7b8a423 100644 --- a/internal/ports/http/handlers.go +++ b/internal/ports/http/handlers.go @@ -2,6 +2,7 @@ package http import ( "encoding/json" + "io" "net/http" "time" @@ -118,12 +119,76 @@ func (h HTTPServer) getSessions(c *echo.Context) error { return c.JSON(http.StatusOK, responseSessions) } -// getSessionLogs retrieves the logs for a specific session based on the session ID provided in the request parameters. +// getSessionLogs retrieves the logs for a specific session. func (h HTTPServer) getSessionLogs(c *echo.Context) error { - return c.String(http.StatusNotImplemented, "Not implemented") + sessionID := c.Param("id") + if sessionID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "missing session id"}) + } + + entries, err := h.logStore.ListLogs(sessionID) + if err != nil { + h.Logr.Error("failed to list logs", zap.String("session_id", sessionID), zap.Error(err)) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to retrieve logs"}) + } + + if len(entries) == 0 { + return c.JSON(http.StatusNotFound, map[string]string{"error": "no logs found for session"}) + } + + files := make([]LogFileResponse, 0, len(entries)) + for _, e := range entries { + files = append(files, LogFileResponse{ + Filename: e.Filename, + Content: e.Content, + }) + } + + return c.JSON(http.StatusOK, SessionLogsResponse{ + SessionID: sessionID, + Files: files, + }) } -// storeSessionLogs stores the logs for a specific session based on the session ID provided in the request parameters. +// storeSessionLogs accepts a multipart upload of three log files for a session. func (h HTTPServer) storeSessionLogs(c *echo.Context) error { - return c.String(http.StatusNotImplemented, "Not implemented") + sessionID := c.Param("id") + if sessionID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "missing session id"}) + } + + stored := 0 + for _, mapping := range storeLogsFormFields { + fh, err := c.FormFile(mapping.Field) + if err != nil { + // field not present in the upload — skip + continue + } + + src, err := fh.Open() + if err != nil { + h.Logr.Error("failed to open uploaded file", zap.String("field", mapping.Field), zap.Error(err)) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "failed to read file: " + mapping.Field}) + } + + data, err := io.ReadAll(src) + src.Close() + if err != nil { + h.Logr.Error("failed to read uploaded file", zap.String("field", mapping.Field), zap.Error(err)) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "failed to read file: " + mapping.Field}) + } + + if err := h.logStore.SaveLog(sessionID, mapping.Filename, data); err != nil { + h.Logr.Error("failed to save log", zap.String("session_id", sessionID), zap.String("filename", mapping.Filename), zap.Error(err)) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to store log file"}) + } + stored++ + } + + if stored == 0 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "no log files provided"}) + } + + h.Logr.Info("stored session logs", zap.String("session_id", sessionID), zap.Int("files", stored)) + return c.JSON(http.StatusCreated, map[string]string{"status": "ok", "session_id": sessionID}) } diff --git a/internal/ports/http/handlers_test.go b/internal/ports/http/handlers_test.go new file mode 100644 index 0000000..712b03d --- /dev/null +++ b/internal/ports/http/handlers_test.go @@ -0,0 +1,222 @@ +package http + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/amirhnajafiz/bedrock-api/internal/components/logs" + "github.com/amirhnajafiz/bedrock-api/internal/storage/gocache" + + "github.com/labstack/echo/v5" + "github.com/labstack/echo/v5/echotest" + "go.uber.org/zap" +) + +func newTestServer() HTTPServer { + return HTTPServer{ + Logr: zap.NewNop(), + logStore: logs.NewLogStore(gocache.NewBackend(time.Minute)), + } +} + +func TestStoreSessionLogs(t *testing.T) { + h := newTestServer() + + rec := echotest.ContextConfig{ + MultipartForm: &echotest.MultipartForm{ + Files: []echotest.MultipartFormFile{ + {Fieldname: "target_log", Filename: "target.log", Content: []byte("target-data")}, + {Fieldname: "tracer_log", Filename: "tracer.log", Content: []byte("tracer-data")}, + {Fieldname: "vfs_pdf", Filename: "vfs.pdf", Content: []byte("pdf-data")}, + }, + }, + PathValues: echo.PathValues{ + {Name: "id", Value: "sess-1"}, + }, + }.ServeWithHandler(t, h.storeSessionLogs) + + if rec.Code != http.StatusCreated { + t.Errorf("POST logs: got status %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + // Verify files were stored. + entries, err := h.logStore.ListLogs("sess-1") + if err != nil { + t.Fatalf("ListLogs: %v", err) + } + if len(entries) != 3 { + t.Fatalf("ListLogs: got %d entries, want 3", len(entries)) + } +} + +func TestStoreSessionLogs_PartialUpload(t *testing.T) { + h := newTestServer() + + rec := echotest.ContextConfig{ + MultipartForm: &echotest.MultipartForm{ + Files: []echotest.MultipartFormFile{ + {Fieldname: "target_log", Filename: "target.log", Content: []byte("only-target")}, + }, + }, + PathValues: echo.PathValues{ + {Name: "id", Value: "sess-2"}, + }, + }.ServeWithHandler(t, h.storeSessionLogs) + + if rec.Code != http.StatusCreated { + t.Errorf("POST partial logs: got status %d, want %d", rec.Code, http.StatusCreated) + } + + entries, err := h.logStore.ListLogs("sess-2") + if err != nil { + t.Fatalf("ListLogs: %v", err) + } + if len(entries) != 1 { + t.Errorf("ListLogs partial: got %d entries, want 1", len(entries)) + } +} + +func TestStoreSessionLogs_NoFiles(t *testing.T) { + h := newTestServer() + + rec := echotest.ContextConfig{ + MultipartForm: &echotest.MultipartForm{}, + PathValues: echo.PathValues{ + {Name: "id", Value: "sess-3"}, + }, + }.ServeWithHandler(t, h.storeSessionLogs) + + if rec.Code != http.StatusBadRequest { + t.Errorf("POST no files: got status %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestStoreSessionLogs_MissingSessionID(t *testing.T) { + h := newTestServer() + + rec := echotest.ContextConfig{ + MultipartForm: &echotest.MultipartForm{ + Files: []echotest.MultipartFormFile{ + {Fieldname: "target_log", Filename: "target.log", Content: []byte("data")}, + }, + }, + }.ServeWithHandler(t, h.storeSessionLogs) + + if rec.Code != http.StatusBadRequest { + t.Errorf("POST missing id: got status %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestGetSessionLogs(t *testing.T) { + h := newTestServer() + + // Pre-populate logs. + _ = h.logStore.SaveLog("sess-1", "target.log", []byte("target-data")) + _ = h.logStore.SaveLog("sess-1", "tracer.log", []byte("tracer-data")) + _ = h.logStore.SaveLog("sess-1", "vfs.pdf", []byte("pdf-data")) + + rec := echotest.ContextConfig{ + PathValues: echo.PathValues{ + {Name: "id", Value: "sess-1"}, + }, + }.ServeWithHandler(t, h.getSessionLogs) + + if rec.Code != http.StatusOK { + t.Fatalf("GET logs: got status %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp SessionLogsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("GET logs: failed to unmarshal response: %v", err) + } + + if resp.SessionID != "sess-1" { + t.Errorf("GET logs: session_id = %q, want %q", resp.SessionID, "sess-1") + } + + if len(resp.Files) != 3 { + t.Fatalf("GET logs: got %d files, want 3", len(resp.Files)) + } + + want := map[string]string{ + "target.log": "target-data", + "tracer.log": "tracer-data", + "vfs.pdf": "pdf-data", + } + for _, f := range resp.Files { + if want[f.Filename] != string(f.Content) { + t.Errorf("GET logs file %s: got %q, want %q", f.Filename, f.Content, want[f.Filename]) + } + } +} + +func TestGetSessionLogs_NotFound(t *testing.T) { + h := newTestServer() + + rec := echotest.ContextConfig{ + PathValues: echo.PathValues{ + {Name: "id", Value: "nonexistent"}, + }, + }.ServeWithHandler(t, h.getSessionLogs) + + if rec.Code != http.StatusNotFound { + t.Errorf("GET missing logs: got status %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestGetSessionLogs_MissingSessionID(t *testing.T) { + h := newTestServer() + + rec := echotest.ContextConfig{}.ServeWithHandler(t, h.getSessionLogs) + + if rec.Code != http.StatusBadRequest { + t.Errorf("GET missing id: got status %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestStoreAndGetSessionLogs_Integration(t *testing.T) { + h := newTestServer() + + // Upload logs via POST handler. + echotest.ContextConfig{ + MultipartForm: &echotest.MultipartForm{ + Files: []echotest.MultipartFormFile{ + {Fieldname: "target_log", Filename: "target.log", Content: []byte("integrated-target")}, + {Fieldname: "tracer_log", Filename: "tracer.log", Content: []byte("integrated-tracer")}, + {Fieldname: "vfs_pdf", Filename: "vfs.pdf", Content: []byte("integrated-pdf")}, + }, + }, + PathValues: echo.PathValues{ + {Name: "id", Value: "sess-int"}, + }, + }.ServeWithHandler(t, h.storeSessionLogs) + + // Retrieve logs via GET handler. + rec := echotest.ContextConfig{ + PathValues: echo.PathValues{ + {Name: "id", Value: "sess-int"}, + }, + }.ServeWithHandler(t, h.getSessionLogs) + + if rec.Code != http.StatusOK { + t.Fatalf("integration GET: got status %d, want %d", rec.Code, http.StatusOK) + } + + var resp SessionLogsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("integration GET: unmarshal: %v", err) + } + + want := map[string]string{ + "target.log": "integrated-target", + "tracer.log": "integrated-tracer", + "vfs.pdf": "integrated-pdf", + } + for _, f := range resp.Files { + if want[f.Filename] != string(f.Content) { + t.Errorf("integration file %s: got %q, want %q", f.Filename, f.Content, want[f.Filename]) + } + } +} diff --git a/internal/ports/http/http.go b/internal/ports/http/http.go index 7ae426f..1b76f3c 100644 --- a/internal/ports/http/http.go +++ b/internal/ports/http/http.go @@ -3,6 +3,7 @@ package http import ( "fmt" + "github.com/amirhnajafiz/bedrock-api/internal/components/logs" "github.com/amirhnajafiz/bedrock-api/internal/components/sessions" zmqclient "github.com/amirhnajafiz/bedrock-api/internal/components/zmq_client" "github.com/amirhnajafiz/bedrock-api/internal/scheduler" @@ -23,6 +24,7 @@ type HTTPServer struct { address string scheduler scheduler.Scheduler sessionStore sessions.SessionStore + logStore logs.LogStore zclient *zmqclient.ZMQClient stateMachine *statemachine.StateMachine } @@ -33,6 +35,7 @@ func (h HTTPServer) Build(address, socketAddress string) *HTTPServer { h.scheduler = scheduler.NewRoundRobin() h.sessionStore = sessions.NewSessionStore(storage.NewGoCache()) + h.logStore = logs.NewLogStore(storage.NewGoCache()) h.zclient = zmqclient.NewZMQClient(socketAddress) h.stateMachine = statemachine.NewStateMachine() diff --git a/internal/ports/http/requests.go b/internal/ports/http/requests.go index 7dc8e2c..664ebe6 100644 --- a/internal/ports/http/requests.go +++ b/internal/ports/http/requests.go @@ -37,3 +37,14 @@ func (r RequestCreateSession) ToSpec() (*models.Spec, error) { type RequestUpdateSession struct { Status enums.SessionStatus `json:"status"` } + +// storeLogsFormFields maps the multipart form field names to log filenames. +// FileMD uploads exactly these three files per session. +var storeLogsFormFields = []struct { + Field string // multipart form field name + Filename string // stored filename +}{ + {Field: "target_log", Filename: "target.log"}, + {Field: "tracer_log", Filename: "tracer.log"}, + {Field: "vfs_pdf", Filename: "vfs.pdf"}, +} diff --git a/internal/ports/http/responses.go b/internal/ports/http/responses.go index 47b280b..b8efbf2 100644 --- a/internal/ports/http/responses.go +++ b/internal/ports/http/responses.go @@ -31,3 +31,15 @@ func ToResponseSession(session *models.Session) *ResponseSession { Status: session.Status.String(), } } + +// LogFileResponse represents a single log file in the GET /sessions/:id/logs response. +type LogFileResponse struct { + Filename string `json:"filename"` + Content []byte `json:"content"` +} + +// SessionLogsResponse is the envelope for the GET /sessions/:id/logs endpoint. +type SessionLogsResponse struct { + SessionID string `json:"session_id"` + Files []LogFileResponse `json:"files"` +}