diff --git a/internal/manager/api_impl/interfaces.go b/internal/manager/api_impl/interfaces.go index 7dcc9632..16e12a4a 100644 --- a/internal/manager/api_impl/interfaces.go +++ b/internal/manager/api_impl/interfaces.go @@ -123,6 +123,7 @@ type LogStorage interface { WriteTimestamped(logger zerolog.Logger, jobID, taskID string, logText string) error RotateFile(logger zerolog.Logger, jobID, taskID string) Tail(jobID, taskID string) (string, error) + TaskLog(jobID, taskID string) (string, error) } // LastRendered processes the "last rendered" images. diff --git a/internal/manager/api_impl/jobs.go b/internal/manager/api_impl/jobs.go index 2f382867..af258bf8 100644 --- a/internal/manager/api_impl/jobs.go +++ b/internal/manager/api_impl/jobs.go @@ -215,6 +215,45 @@ func (f *Flamenco) SetTaskStatus(e echo.Context, taskID string) error { return e.NoContent(http.StatusNoContent) } +func (f *Flamenco) FetchTaskLog(e echo.Context, taskID string) error { + logger := requestLogger(e) + ctx := e.Request().Context() + + logger = logger.With().Str("task", taskID).Logger() + if !uuid.IsValid(taskID) { + logger.Warn().Msg("fetchTaskLog: bad task ID ") + return sendAPIError(e, http.StatusBadRequest, "bad task ID") + } + + dbTask, err := f.persist.FetchTask(ctx, taskID) + if err != nil { + if errors.Is(err, persistence.ErrTaskNotFound) { + return sendAPIError(e, http.StatusNotFound, "no such task") + } + logger.Error().Err(err).Msg("error fetching task") + return sendAPIError(e, http.StatusInternalServerError, "error fetching task: %v", err) + } + logger = logger.With().Str("job", dbTask.Job.UUID).Logger() + + fullLog, err := f.logStorage.TaskLog(dbTask.Job.UUID, taskID) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + logger.Debug().Msg("task log unavailable, task has no log on disk") + return e.NoContent(http.StatusNoContent) + } + logger.Error().Err(err).Msg("unable to fetch task log") + return sendAPIError(e, http.StatusInternalServerError, "error fetching task log: %v", err) + } + + if fullLog == "" { + logger.Debug().Msg("task log unavailable, on-disk task log is empty") + return e.NoContent(http.StatusNoContent) + } + + logger.Debug().Msg("fetched task log") + return e.String(http.StatusOK, fullLog) +} + func (f *Flamenco) FetchTaskLogTail(e echo.Context, taskID string) error { logger := requestLogger(e) ctx := e.Request().Context() diff --git a/internal/manager/api_impl/mocks/api_impl_mock.gen.go b/internal/manager/api_impl/mocks/api_impl_mock.gen.go index 17c1e8e5..3e817913 100644 --- a/internal/manager/api_impl/mocks/api_impl_mock.gen.go +++ b/internal/manager/api_impl/mocks/api_impl_mock.gen.go @@ -620,6 +620,21 @@ func (mr *MockLogStorageMockRecorder) Tail(arg0, arg1 interface{}) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Tail", reflect.TypeOf((*MockLogStorage)(nil).Tail), arg0, arg1) } +// TaskLog mocks base method. +func (m *MockLogStorage) TaskLog(arg0, arg1 string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TaskLog", arg0, arg1) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// TaskLog indicates an expected call of TaskLog. +func (mr *MockLogStorageMockRecorder) TaskLog(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TaskLog", reflect.TypeOf((*MockLogStorage)(nil).TaskLog), arg0, arg1) +} + // Write mocks base method. func (m *MockLogStorage) Write(arg0 zerolog.Logger, arg1, arg2, arg3 string) error { m.ctrl.T.Helper() diff --git a/internal/manager/task_logs/task_logs.go b/internal/manager/task_logs/task_logs.go index 561be1ef..52817a51 100644 --- a/internal/manager/task_logs/task_logs.go +++ b/internal/manager/task_logs/task_logs.go @@ -159,6 +159,20 @@ func (s *Storage) filepath(jobID, taskID string) string { return path.Join(dirpath, filename) } +// TaskLog reads the entire log file. +func (s *Storage) TaskLog(jobID, taskID string) (string, error) { + filepath := s.filepath(jobID, taskID) + + s.taskLock(taskID) + defer s.taskUnlock(taskID) + + buffer, err := os.ReadFile(filepath) + if err != nil { + return "", fmt.Errorf("reading log file of job %q task %q: %w", jobID, taskID, err) + } + return string(buffer), nil +} + // Tail reads the final few lines of a task log. func (s *Storage) Tail(jobID, taskID string) (string, error) { filepath := s.filepath(jobID, taskID) diff --git a/internal/manager/task_logs/task_logs_test.go b/internal/manager/task_logs/task_logs_test.go index 3cc0a203..9296ba74 100644 --- a/internal/manager/task_logs/task_logs_test.go +++ b/internal/manager/task_logs/task_logs_test.go @@ -74,7 +74,7 @@ func TestLogRotation(t *testing.T) { assert.True(t, errors.Is(err, fs.ErrNotExist)) } -func TestLogTail(t *testing.T) { +func TestLogTailAndFullLog(t *testing.T) { s, finish, mocks := taskLogsTestFixtures(t) defer finish() @@ -110,6 +110,14 @@ func TestLogTail(t *testing.T) { err = s.Write(zerolog.Nop(), jobID, taskID, bigString) assert.NoError(t, err) + // Check the full log, it should be the entire bigString plus what was written before that. + contents, err = s.TaskLog(jobID, taskID) + if assert.NoError(t, err) { + expect := "Just a single line\nAnd another line!\n" + bigString + assert.Equal(t, expect, contents) + } + + // Check the tail, it should only be the few last lines of bigString. contents, err = s.Tail(jobID, taskID) assert.NoError(t, err) assert.Equal(t,