diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index a8140992..a4c83bcd 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -2,26 +2,35 @@ package api import ( "context" + "fmt" + "time" "github.com/onkernel/kernel-images/server/lib/logger" oapi "github.com/onkernel/kernel-images/server/lib/oapi" "github.com/onkernel/kernel-images/server/lib/recorder" ) -// ApiService implements the API endpoints -// It manages a single recording session and provides endpoints for starting, stopping, and downloading it type ApiService struct { - mainRecorderID string // ID used for the primary recording session - recordManager recorder.RecordManager - factory recorder.FFmpegRecorderFactory + // defaultRecorderID is used whenever the caller doesn't specify an explicit ID. + defaultRecorderID string + + recordManager recorder.RecordManager + factory recorder.FFmpegRecorderFactory } -func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFactory) *ApiService { - return &ApiService{ - recordManager: recordManager, - factory: factory, - mainRecorderID: "main", // use a single recorder for now +func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFactory) (*ApiService, error) { + switch { + case recordManager == nil: + return nil, fmt.Errorf("recordManager cannot be nil") + case factory == nil: + return nil, fmt.Errorf("factory cannot be nil") } + + return &ApiService{ + recordManager: recordManager, + factory: factory, + defaultRecorderID: "default", + }, nil } func (s *ApiService) StartRecording(ctx context.Context, req oapi.StartRecordingRequestObject) (oapi.StartRecordingResponseObject, error) { @@ -31,26 +40,38 @@ func (s *ApiService) StartRecording(ctx context.Context, req oapi.StartRecording if req.Body != nil { params.FrameRate = req.Body.Framerate params.MaxSizeInMB = req.Body.MaxFileSizeInMB + params.MaxDurationInSeconds = req.Body.MaxDurationInSeconds + } + + // Determine recorder ID (use default if none provided) + recorderID := s.defaultRecorderID + if req.Body != nil && req.Body.Id != nil && *req.Body.Id != "" { + recorderID = *req.Body.Id } // Create, register, and start a new recorder - rec, err := s.factory(s.mainRecorderID, params) + rec, err := s.factory(recorderID, params) if err != nil { - log.Error("failed to create recorder", "err", err) + log.Error("failed to create recorder", "err", err, "recorder_id", recorderID) return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to create recording"}}, nil } if err := s.recordManager.RegisterRecorder(ctx, rec); err != nil { - if rec, exists := s.recordManager.GetRecorder(s.mainRecorderID); exists && rec.IsRecording(ctx) { - log.Error("attempted to start recording while one is already active") - return oapi.StartRecording409JSONResponse{ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{Message: "recording already in progress"}}, nil + if rec, exists := s.recordManager.GetRecorder(recorderID); exists { + if rec.IsRecording(ctx) { + log.Error("attempted to start recording while one is already active", "recorder_id", recorderID) + return oapi.StartRecording409JSONResponse{ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{Message: "recording already in progress"}}, nil + } else { + log.Error("attempted to restart recording", "recorder_id", recorderID) + return oapi.StartRecording400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "recording already completed"}}, nil + } } - log.Error("failed to register recorder", "err", err) + log.Error("failed to register recorder", "err", err, "recorder_id", recorderID) return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to register recording"}}, nil } if err := rec.Start(ctx); err != nil { - log.Error("failed to start recording", "err", err) - // ensure the recorder is deregistered if we fail to start + log.Error("failed to start recording", "err", err, "recorder_id", recorderID) + // ensure the recorder is deregistered defer s.recordManager.DeregisterRecorder(ctx, rec) return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to start recording"}}, nil } @@ -61,10 +82,19 @@ func (s *ApiService) StartRecording(ctx context.Context, req oapi.StartRecording func (s *ApiService) StopRecording(ctx context.Context, req oapi.StopRecordingRequestObject) (oapi.StopRecordingResponseObject, error) { log := logger.FromContext(ctx) - rec, exists := s.recordManager.GetRecorder(s.mainRecorderID) - if !exists || !rec.IsRecording(ctx) { - log.Warn("attempted to stop recording when none is active") + // Determine recorder ID + recorderID := s.defaultRecorderID + if req.Body != nil && req.Body.Id != nil && *req.Body.Id != "" { + recorderID = *req.Body.Id + } + + rec, exists := s.recordManager.GetRecorder(recorderID) + if !exists { + log.Warn("attempted to stop recording when none is active", "recorder_id", recorderID) return oapi.StopRecording400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "no active recording to stop"}}, nil + } else if !rec.IsRecording(ctx) { + log.Warn("recording already stopped", "recorder_id", recorderID) + return oapi.StopRecording200Response{}, nil } // Check if force stop is requested @@ -75,15 +105,15 @@ func (s *ApiService) StopRecording(ctx context.Context, req oapi.StopRecordingRe var err error if forceStop { - log.Info("force stopping recording") + log.Info("force stopping recording", "recorder_id", recorderID) err = rec.ForceStop(ctx) } else { - log.Info("gracefully stopping recording") + log.Info("gracefully stopping recording", "recorder_id", recorderID) err = rec.Stop(ctx) } if err != nil { - log.Error("error occurred while stopping recording", "err", err, "force", forceStop) + log.Error("error occurred while stopping recording", "err", err, "force", forceStop, "recorder_id", recorderID) } return oapi.StopRecording200Response{}, nil @@ -96,16 +126,22 @@ const ( func (s *ApiService) DownloadRecording(ctx context.Context, req oapi.DownloadRecordingRequestObject) (oapi.DownloadRecordingResponseObject, error) { log := logger.FromContext(ctx) + // Determine recorder ID + recorderID := s.defaultRecorderID + if req.Params.Id != nil && *req.Params.Id != "" { + recorderID = *req.Params.Id + } + // Get the recorder to access its output path - rec, exists := s.recordManager.GetRecorder(s.mainRecorderID) + rec, exists := s.recordManager.GetRecorder(recorderID) if !exists { - log.Error("attempted to download non-existent recording") + log.Error("attempted to download non-existent recording", "recorder_id", recorderID) return oapi.DownloadRecording404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "no recording found"}}, nil } out, meta, err := rec.Recording(ctx) if err != nil { - log.Error("failed to get recording", "err", err) + log.Error("failed to get recording", "err", err, "recorder_id", recorderID) return oapi.DownloadRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to get recording"}}, nil } @@ -118,13 +154,41 @@ func (s *ApiService) DownloadRecording(ctx context.Context, req oapi.DownloadRec }, nil } - log.Info("serving recording file for download", "size", meta.Size) + log.Info("serving recording file for download", "size", meta.Size, "recorder_id", recorderID) return oapi.DownloadRecording200Videomp4Response{ - Body: out, + Body: out, + Headers: oapi.DownloadRecording200ResponseHeaders{ + XRecordingStartedAt: meta.StartTime.Format(time.RFC3339), + XRecordingFinishedAt: meta.EndTime.Format(time.RFC3339), + }, ContentLength: meta.Size, }, nil } +// ListRecorders returns a list of all registered recorders and whether each one is currently recording. +func (s *ApiService) ListRecorders(ctx context.Context, _ oapi.ListRecordersRequestObject) (oapi.ListRecordersResponseObject, error) { + infos := []oapi.RecorderInfo{} + + timeOrNil := func(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + return &t + } + + recs := s.recordManager.ListActiveRecorders(ctx) + for _, r := range recs { + m := r.Metadata() + infos = append(infos, oapi.RecorderInfo{ + Id: r.ID(), + IsRecording: r.IsRecording(ctx), + StartedAt: timeOrNil(m.StartTime), + FinishedAt: timeOrNil(m.EndTime), + }) + } + return oapi.ListRecorders200JSONResponse(infos), nil +} + func (s *ApiService) Shutdown(ctx context.Context) error { return s.recordManager.StopAll(ctx) } diff --git a/server/cmd/api/api/api_test.go b/server/cmd/api/api/api_test.go index 7366256d..76beb446 100644 --- a/server/cmd/api/api/api_test.go +++ b/server/cmd/api/api/api_test.go @@ -3,12 +3,14 @@ package api import ( "bytes" "context" + "fmt" "io" "math/rand" "testing" oapi "github.com/onkernel/kernel-images/server/lib/oapi" "github.com/onkernel/kernel-images/server/lib/recorder" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -17,23 +19,25 @@ func TestApiService_StartRecording(t *testing.T) { t.Run("success", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) resp, err := svc.StartRecording(ctx, oapi.StartRecordingRequestObject{}) require.NoError(t, err) require.IsType(t, oapi.StartRecording201Response{}, resp) - rec, exists := mgr.GetRecorder("main") + rec, exists := mgr.GetRecorder("default") require.True(t, exists, "recorder was not registered") require.True(t, rec.IsRecording(ctx), "recorder should be recording after Start") }) t.Run("already recording", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) // First start should succeed - _, err := svc.StartRecording(ctx, oapi.StartRecordingRequestObject{}) + _, err = svc.StartRecording(ctx, oapi.StartRecordingRequestObject{}) require.NoError(t, err) // Second start should return conflict @@ -41,6 +45,35 @@ func TestApiService_StartRecording(t *testing.T) { require.NoError(t, err) require.IsType(t, oapi.StartRecording409JSONResponse{}, resp) }) + + t.Run("custom ids don't collide", func(t *testing.T) { + mgr := recorder.NewFFmpegManager() + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) + + for i := 0; i < 5; i++ { + customID := fmt.Sprintf("rec-%d", i) + resp, err := svc.StartRecording(ctx, oapi.StartRecordingRequestObject{Body: &oapi.StartRecordingJSONRequestBody{Id: &customID}}) + require.NoError(t, err) + require.IsType(t, oapi.StartRecording201Response{}, resp) + + rec, exists := mgr.GetRecorder(customID) + assert.True(t, exists) + assert.True(t, rec.IsRecording(ctx)) + } + + out := mgr.ListActiveRecorders(ctx) + assert.Equal(t, 5, len(out)) + for _, rec := range out { + assert.NotEqual(t, "default", rec.ID()) + } + + err = mgr.StopAll(ctx) + require.NoError(t, err) + + out = mgr.ListActiveRecorders(ctx) + assert.Equal(t, 5, len(out)) + }) } func TestApiService_StopRecording(t *testing.T) { @@ -48,7 +81,8 @@ func TestApiService_StopRecording(t *testing.T) { t.Run("no active recording", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) resp, err := svc.StopRecording(ctx, oapi.StopRecordingRequestObject{}) require.NoError(t, err) @@ -57,10 +91,11 @@ func TestApiService_StopRecording(t *testing.T) { t.Run("graceful stop", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - rec := &mockRecorder{id: "main", isRecordingFlag: true} + rec := &mockRecorder{id: "default", isRecordingFlag: true} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) resp, err := svc.StopRecording(ctx, oapi.StopRecordingRequestObject{}) require.NoError(t, err) require.IsType(t, oapi.StopRecording200Response{}, resp) @@ -69,12 +104,13 @@ func TestApiService_StopRecording(t *testing.T) { t.Run("force stop", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - rec := &mockRecorder{id: "main", isRecordingFlag: true} + rec := &mockRecorder{id: "default", isRecordingFlag: true} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") force := true req := oapi.StopRecordingRequestObject{Body: &oapi.StopRecordingJSONRequestBody{ForceStop: &force}} - svc := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) resp, err := svc.StopRecording(ctx, req) require.NoError(t, err) require.IsType(t, oapi.StopRecording200Response{}, resp) @@ -87,7 +123,8 @@ func TestApiService_DownloadRecording(t *testing.T) { t.Run("not found", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) resp, err := svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{}) require.NoError(t, err) require.IsType(t, oapi.DownloadRecording404JSONResponse{}, resp) @@ -103,10 +140,11 @@ func TestApiService_DownloadRecording(t *testing.T) { t.Run("still recording", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - rec := &mockRecorder{id: "main", isRecordingFlag: true, recordingData: randomBytes(minRecordingSizeInBytes - 1)} + rec := &mockRecorder{id: "default", isRecordingFlag: true, recordingData: randomBytes(minRecordingSizeInBytes - 1)} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) // will return a 202 when the recording is too small resp, err := svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{}) require.NoError(t, err) @@ -132,10 +170,11 @@ func TestApiService_DownloadRecording(t *testing.T) { t.Run("success", func(t *testing.T) { mgr := recorder.NewFFmpegManager() data := []byte("dummy video data") - rec := &mockRecorder{id: "main", recordingData: data} + rec := &mockRecorder{id: "default", recordingData: data} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) resp, err := svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{}) require.NoError(t, err) r, ok := resp.(oapi.DownloadRecording200Videomp4Response) @@ -151,10 +190,11 @@ func TestApiService_DownloadRecording(t *testing.T) { func TestApiService_Shutdown(t *testing.T) { ctx := context.Background() mgr := recorder.NewFFmpegManager() - rec := &mockRecorder{id: "main", isRecordingFlag: true} + rec := &mockRecorder{id: "default", isRecordingFlag: true} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) require.NoError(t, svc.Shutdown(ctx)) require.True(t, rec.stopCalled, "Shutdown should have stopped active recorder") @@ -219,6 +259,10 @@ func (m *mockRecorder) Recording(ctx context.Context) (io.ReadCloser, *recorder. return reader, meta, nil } +func (m *mockRecorder) Metadata() *recorder.RecordingMetadata { + return &recorder.RecordingMetadata{} +} + func newMockFactory() recorder.FFmpegRecorderFactory { return func(id string, _ recorder.FFmpegRecordingParams) (recorder.Recorder, error) { rec := &mockRecorder{id: id} diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 536e3b20..50a56a14 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -22,6 +22,7 @@ import ( "github.com/onkernel/kernel-images/server/lib/logger" oapi "github.com/onkernel/kernel-images/server/lib/oapi" "github.com/onkernel/kernel-images/server/lib/recorder" + "github.com/onkernel/kernel-images/server/lib/scaletozero" ) func main() { @@ -64,7 +65,16 @@ func main() { slogger.Error("invalid default recording parameters", "err", err) os.Exit(1) } - apiService := api.New(recorder.NewFFmpegManager(), recorder.NewFFmpegRecorderFactory(config.PathToFFmpeg, defaultParams)) + stz := scaletozero.NewUnikraftCloudController() + + apiService, err := api.New( + recorder.NewFFmpegManager(), + recorder.NewFFmpegRecorderFactory(config.PathToFFmpeg, defaultParams, stz), + ) + if err != nil { + slogger.Error("failed to create api service", "err", err) + os.Exit(1) + } strictHandler := oapi.NewStrictHandler(apiService, nil) oapi.HandlerFromMux(strictHandler, r) diff --git a/server/go.mod b/server/go.mod index 38925a73..3ed05aeb 100644 --- a/server/go.mod +++ b/server/go.mod @@ -13,9 +13,11 @@ require ( ) require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/google/uuid v1.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect diff --git a/server/go.sum b/server/go.sum index d120fb9c..2adcffee 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,3 +1,8 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk= @@ -12,8 +17,11 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -36,6 +44,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 8bce6db3..4958b5aa 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -15,9 +15,11 @@ import ( "net/url" "path" "strings" + "time" "github.com/getkin/kin-openapi/openapi3" "github.com/go-chi/chi/v5" + "github.com/oapi-codegen/runtime" strictnethttp "github.com/oapi-codegen/runtime/strictmiddleware/nethttp" ) @@ -26,11 +28,28 @@ type Error struct { Message string `json:"message"` } +// RecorderInfo defines model for RecorderInfo. +type RecorderInfo struct { + // FinishedAt Timestamp when recording finished + FinishedAt *time.Time `json:"finished_at"` + Id string `json:"id"` + IsRecording bool `json:"isRecording"` + + // StartedAt Timestamp when recording started + StartedAt *time.Time `json:"started_at"` +} + // StartRecordingRequest defines model for StartRecordingRequest. type StartRecordingRequest struct { // Framerate Recording framerate in fps (overrides server default) Framerate *int `json:"framerate,omitempty"` + // Id Optional identifier for the recording session. Alphanumeric or hyphen. + Id *string `json:"id,omitempty"` + + // MaxDurationInSeconds Maximum recording duration in seconds (overrides server default) + MaxDurationInSeconds *int `json:"maxDurationInSeconds,omitempty"` + // MaxFileSizeInMB Maximum file size in MB (overrides server default) MaxFileSizeInMB *int `json:"maxFileSizeInMB,omitempty"` } @@ -39,6 +58,9 @@ type StartRecordingRequest struct { type StopRecordingRequest struct { // ForceStop Immediately stop without graceful shutdown. This may result in a corrupted video file. ForceStop *bool `json:"forceStop,omitempty"` + + // Id Identifier of the recorder to stop. Alphanumeric or hyphen. + Id *string `json:"id,omitempty"` } // BadRequestError defines model for BadRequestError. @@ -53,6 +75,12 @@ type InternalError = Error // NotFoundError defines model for NotFoundError. type NotFoundError = Error +// DownloadRecordingParams defines parameters for DownloadRecording. +type DownloadRecordingParams struct { + // Id Optional recorder identifier. When omitted, the server uses the default recorder. + Id *string `form:"id,omitempty" json:"id,omitempty"` +} + // StartRecordingJSONRequestBody defines body for StartRecording for application/json ContentType. type StartRecordingJSONRequestBody = StartRecordingRequest @@ -133,7 +161,10 @@ func WithRequestEditorFn(fn RequestEditorFn) ClientOption { // The interface specification for the client above. type ClientInterface interface { // DownloadRecording request - DownloadRecording(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + DownloadRecording(ctx context.Context, params *DownloadRecordingParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListRecorders request + ListRecorders(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) // StartRecordingWithBody request with any body StartRecordingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -146,8 +177,20 @@ type ClientInterface interface { StopRecording(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) } -func (c *Client) DownloadRecording(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewDownloadRecordingRequest(c.Server) +func (c *Client) DownloadRecording(ctx context.Context, params *DownloadRecordingParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDownloadRecordingRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ListRecorders(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListRecordersRequest(c.Server) if err != nil { return nil, err } @@ -207,7 +250,7 @@ func (c *Client) StopRecording(ctx context.Context, body StopRecordingJSONReques } // NewDownloadRecordingRequest generates requests for DownloadRecording -func NewDownloadRecordingRequest(server string) (*http.Request, error) { +func NewDownloadRecordingRequest(server string, params *DownloadRecordingParams) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -225,6 +268,55 @@ func NewDownloadRecordingRequest(server string) (*http.Request, error) { return nil, err } + if params != nil { + queryValues := queryURL.Query() + + if params.Id != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "id", runtime.ParamLocationQuery, *params.Id); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewListRecordersRequest generates requests for ListRecorders +func NewListRecordersRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/recording/list") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + req, err := http.NewRequest("GET", queryURL.String(), nil) if err != nil { return nil, err @@ -357,7 +449,10 @@ func WithBaseURL(baseURL string) ClientOption { // ClientWithResponsesInterface is the interface specification for the client with responses above. type ClientWithResponsesInterface interface { // DownloadRecordingWithResponse request - DownloadRecordingWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DownloadRecordingResponse, error) + DownloadRecordingWithResponse(ctx context.Context, params *DownloadRecordingParams, reqEditors ...RequestEditorFn) (*DownloadRecordingResponse, error) + + // ListRecordersWithResponse request + ListRecordersWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListRecordersResponse, error) // StartRecordingWithBodyWithResponse request with any body StartRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartRecordingResponse, error) @@ -394,9 +489,33 @@ func (r DownloadRecordingResponse) StatusCode() int { return 0 } +type ListRecordersResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *[]RecorderInfo + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r ListRecordersResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListRecordersResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type StartRecordingResponse struct { Body []byte HTTPResponse *http.Response + JSON400 *BadRequestError JSON409 *ConflictError JSON500 *InternalError } @@ -441,14 +560,23 @@ func (r StopRecordingResponse) StatusCode() int { } // DownloadRecordingWithResponse request returning *DownloadRecordingResponse -func (c *ClientWithResponses) DownloadRecordingWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DownloadRecordingResponse, error) { - rsp, err := c.DownloadRecording(ctx, reqEditors...) +func (c *ClientWithResponses) DownloadRecordingWithResponse(ctx context.Context, params *DownloadRecordingParams, reqEditors ...RequestEditorFn) (*DownloadRecordingResponse, error) { + rsp, err := c.DownloadRecording(ctx, params, reqEditors...) if err != nil { return nil, err } return ParseDownloadRecordingResponse(rsp) } +// ListRecordersWithResponse request returning *ListRecordersResponse +func (c *ClientWithResponses) ListRecordersWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListRecordersResponse, error) { + rsp, err := c.ListRecorders(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseListRecordersResponse(rsp) +} + // StartRecordingWithBodyWithResponse request with arbitrary body returning *StartRecordingResponse func (c *ClientWithResponses) StartRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartRecordingResponse, error) { rsp, err := c.StartRecordingWithBody(ctx, contentType, body, reqEditors...) @@ -523,6 +651,39 @@ func ParseDownloadRecordingResponse(rsp *http.Response) (*DownloadRecordingRespo return response, nil } +// ParseListRecordersResponse parses an HTTP response from a ListRecordersWithResponse call +func ParseListRecordersResponse(rsp *http.Response) (*ListRecordersResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListRecordersResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest []RecorderInfo + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseStartRecordingResponse parses an HTTP response from a StartRecordingWithResponse call func ParseStartRecordingResponse(rsp *http.Response) (*StartRecordingResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -537,6 +698,13 @@ func ParseStartRecordingResponse(rsp *http.Response) (*StartRecordingResponse, e } switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: var dest ConflictError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -593,11 +761,14 @@ func ParseStopRecordingResponse(rsp *http.Response) (*StopRecordingResponse, err type ServerInterface interface { // Download the most recently recorded video file // (GET /recording/download) - DownloadRecording(w http.ResponseWriter, r *http.Request) - // Start a screen recording. Only one recording can be active at a time. + DownloadRecording(w http.ResponseWriter, r *http.Request, params DownloadRecordingParams) + // List all recorders + // (GET /recording/list) + ListRecorders(w http.ResponseWriter, r *http.Request) + // Start a screen recording. Only one recording per ID can be registered at a time. // (POST /recording/start) StartRecording(w http.ResponseWriter, r *http.Request) - // Stop the current recording + // Stop the recording // (POST /recording/stop) StopRecording(w http.ResponseWriter, r *http.Request) } @@ -608,17 +779,23 @@ type Unimplemented struct{} // Download the most recently recorded video file // (GET /recording/download) -func (_ Unimplemented) DownloadRecording(w http.ResponseWriter, r *http.Request) { +func (_ Unimplemented) DownloadRecording(w http.ResponseWriter, r *http.Request, params DownloadRecordingParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// List all recorders +// (GET /recording/list) +func (_ Unimplemented) ListRecorders(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } -// Start a screen recording. Only one recording can be active at a time. +// Start a screen recording. Only one recording per ID can be registered at a time. // (POST /recording/start) func (_ Unimplemented) StartRecording(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } -// Stop the current recording +// Stop the recording // (POST /recording/stop) func (_ Unimplemented) StopRecording(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) @@ -636,8 +813,35 @@ type MiddlewareFunc func(http.Handler) http.Handler // DownloadRecording operation middleware func (siw *ServerInterfaceWrapper) DownloadRecording(w http.ResponseWriter, r *http.Request) { + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params DownloadRecordingParams + + // ------------- Optional query parameter "id" ------------- + + err = runtime.BindQueryParameter("form", true, false, "id", r.URL.Query(), ¶ms.Id) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DownloadRecording(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ListRecorders operation middleware +func (siw *ServerInterfaceWrapper) ListRecorders(w http.ResponseWriter, r *http.Request) { + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.DownloadRecording(w, r) + siw.Handler.ListRecorders(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -791,6 +995,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/recording/download", wrapper.DownloadRecording) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/recording/list", wrapper.ListRecorders) + }) r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/recording/start", wrapper.StartRecording) }) @@ -810,14 +1017,21 @@ type InternalErrorJSONResponse Error type NotFoundErrorJSONResponse Error type DownloadRecordingRequestObject struct { + Params DownloadRecordingParams } type DownloadRecordingResponseObject interface { VisitDownloadRecordingResponse(w http.ResponseWriter) error } +type DownloadRecording200ResponseHeaders struct { + XRecordingFinishedAt string + XRecordingStartedAt string +} + type DownloadRecording200Videomp4Response struct { Body io.Reader + Headers DownloadRecording200ResponseHeaders ContentLength int64 } @@ -826,6 +1040,8 @@ func (response DownloadRecording200Videomp4Response) VisitDownloadRecordingRespo if response.ContentLength != 0 { w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) } + w.Header().Set("X-Recording-Finished-At", fmt.Sprint(response.Headers.XRecordingFinishedAt)) + w.Header().Set("X-Recording-Started-At", fmt.Sprint(response.Headers.XRecordingStartedAt)) w.WriteHeader(200) if closer, ok := response.Body.(io.ReadCloser); ok { @@ -876,6 +1092,31 @@ func (response DownloadRecording500JSONResponse) VisitDownloadRecordingResponse( return json.NewEncoder(w).Encode(response) } +type ListRecordersRequestObject struct { +} + +type ListRecordersResponseObject interface { + VisitListRecordersResponse(w http.ResponseWriter) error +} + +type ListRecorders200JSONResponse []RecorderInfo + +func (response ListRecorders200JSONResponse) VisitListRecordersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ListRecorders500JSONResponse struct{ InternalErrorJSONResponse } + +func (response ListRecorders500JSONResponse) VisitListRecordersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type StartRecordingRequestObject struct { Body *StartRecordingJSONRequestBody } @@ -892,6 +1133,15 @@ func (response StartRecording201Response) VisitStartRecordingResponse(w http.Res return nil } +type StartRecording400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response StartRecording400JSONResponse) VisitStartRecordingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + type StartRecording409JSONResponse struct{ ConflictErrorJSONResponse } func (response StartRecording409JSONResponse) VisitStartRecordingResponse(w http.ResponseWriter) error { @@ -949,10 +1199,13 @@ type StrictServerInterface interface { // Download the most recently recorded video file // (GET /recording/download) DownloadRecording(ctx context.Context, request DownloadRecordingRequestObject) (DownloadRecordingResponseObject, error) - // Start a screen recording. Only one recording can be active at a time. + // List all recorders + // (GET /recording/list) + ListRecorders(ctx context.Context, request ListRecordersRequestObject) (ListRecordersResponseObject, error) + // Start a screen recording. Only one recording per ID can be registered at a time. // (POST /recording/start) StartRecording(ctx context.Context, request StartRecordingRequestObject) (StartRecordingResponseObject, error) - // Stop the current recording + // Stop the recording // (POST /recording/stop) StopRecording(ctx context.Context, request StopRecordingRequestObject) (StopRecordingResponseObject, error) } @@ -987,9 +1240,11 @@ type strictHandler struct { } // DownloadRecording operation middleware -func (sh *strictHandler) DownloadRecording(w http.ResponseWriter, r *http.Request) { +func (sh *strictHandler) DownloadRecording(w http.ResponseWriter, r *http.Request, params DownloadRecordingParams) { var request DownloadRecordingRequestObject + request.Params = params + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { return sh.ssi.DownloadRecording(ctx, request.(DownloadRecordingRequestObject)) } @@ -1010,6 +1265,30 @@ func (sh *strictHandler) DownloadRecording(w http.ResponseWriter, r *http.Reques } } +// ListRecorders operation middleware +func (sh *strictHandler) ListRecorders(w http.ResponseWriter, r *http.Request) { + var request ListRecordersRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ListRecorders(ctx, request.(ListRecordersRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListRecorders") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ListRecordersResponseObject); ok { + if err := validResponse.VisitListRecordersResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // StartRecording operation middleware func (sh *strictHandler) StartRecording(w http.ResponseWriter, r *http.Request) { var request StartRecordingRequestObject @@ -1075,20 +1354,27 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "D8PxkwP5NwAA///q759s/hAAAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/lib/recorder/ffmeg_test.go b/server/lib/recorder/ffmeg_test.go index 47e3a03e..9ef38125 100644 --- a/server/lib/recorder/ffmeg_test.go +++ b/server/lib/recorder/ffmeg_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/onkernel/kernel-images/server/lib/scaletozero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -30,6 +31,7 @@ func TestFFmpegRecorder_StartAndStop(t *testing.T) { id: "startstop", binaryPath: mockBin, params: defaultParams(t.TempDir()), + stz: scaletozero.NewOncer(scaletozero.NewNoopController()), } require.NoError(t, rec.Start(t.Context())) require.True(t, rec.IsRecording(t.Context())) @@ -46,6 +48,7 @@ func TestFFmpegRecorder_ForceStop(t *testing.T) { id: "startstop", binaryPath: mockBin, params: defaultParams(t.TempDir()), + stz: scaletozero.NewOncer(scaletozero.NewNoopController()), } require.NoError(t, rec.Start(t.Context())) require.True(t, rec.IsRecording(t.Context())) diff --git a/server/lib/recorder/ffmpeg.go b/server/lib/recorder/ffmpeg.go index b3dbbe3a..80e5dd93 100644 --- a/server/lib/recorder/ffmpeg.go +++ b/server/lib/recorder/ffmpeg.go @@ -17,6 +17,7 @@ import ( "time" "github.com/onkernel/kernel-images/server/lib/logger" + "github.com/onkernel/kernel-images/server/lib/scaletozero" ) const ( @@ -45,13 +46,16 @@ type FFmpegRecorder struct { ffmpegErr error exitCode int exited chan struct{} + stz *scaletozero.Oncer } type FFmpegRecordingParams struct { FrameRate *int DisplayNum *int MaxSizeInMB *int - OutputDir *string + // MaxDurationInSeconds optionally limits the total recording time. If nil there is no duration limit. + MaxDurationInSeconds *int + OutputDir *string } func (p FFmpegRecordingParams) Validate() error { @@ -67,6 +71,9 @@ func (p FFmpegRecordingParams) Validate() error { if p.MaxSizeInMB == nil { return fmt.Errorf("max size in MB is required") } + if p.MaxDurationInSeconds != nil && *p.MaxDurationInSeconds <= 0 { + return fmt.Errorf("max duration must be greater than 0 seconds") + } return nil } @@ -76,7 +83,7 @@ type FFmpegRecorderFactory func(id string, overrides FFmpegRecordingParams) (Rec // NewFFmpegRecorderFactory returns a factory that creates new recorders. The provided // pathToFFmpeg is used as the binary to execute; if empty it defaults to "ffmpeg" which // is expected to be discoverable on the host's PATH. -func NewFFmpegRecorderFactory(pathToFFmpeg string, config FFmpegRecordingParams) FFmpegRecorderFactory { +func NewFFmpegRecorderFactory(pathToFFmpeg string, config FFmpegRecordingParams, ctrl scaletozero.Controller) FFmpegRecorderFactory { return func(id string, overrides FFmpegRecordingParams) (Recorder, error) { mergedParams := mergeFFmpegRecordingParams(config, overrides) return &FFmpegRecorder{ @@ -84,16 +91,18 @@ func NewFFmpegRecorderFactory(pathToFFmpeg string, config FFmpegRecordingParams) binaryPath: pathToFFmpeg, outputPath: filepath.Join(*mergedParams.OutputDir, fmt.Sprintf("%s.mp4", id)), params: mergedParams, + stz: scaletozero.NewOncer(ctrl), }, nil } } func mergeFFmpegRecordingParams(config FFmpegRecordingParams, overrides FFmpegRecordingParams) FFmpegRecordingParams { merged := FFmpegRecordingParams{ - FrameRate: config.FrameRate, - DisplayNum: config.DisplayNum, - MaxSizeInMB: config.MaxSizeInMB, - OutputDir: config.OutputDir, + FrameRate: config.FrameRate, + DisplayNum: config.DisplayNum, + MaxSizeInMB: config.MaxSizeInMB, + MaxDurationInSeconds: config.MaxDurationInSeconds, + OutputDir: config.OutputDir, } if overrides.FrameRate != nil { merged.FrameRate = overrides.FrameRate @@ -104,6 +113,9 @@ func mergeFFmpegRecordingParams(config FFmpegRecordingParams, overrides FFmpegRe if overrides.MaxSizeInMB != nil { merged.MaxSizeInMB = overrides.MaxSizeInMB } + if overrides.MaxDurationInSeconds != nil { + merged.MaxDurationInSeconds = overrides.MaxDurationInSeconds + } if overrides.OutputDir != nil { merged.OutputDir = overrides.OutputDir } @@ -122,9 +134,15 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { fr.mu.Lock() if fr.cmd != nil { + fr.mu.Unlock() return fmt.Errorf("recording already in progress") } + if err := fr.stz.Disable(ctx); err != nil { + fr.mu.Unlock() + return fmt.Errorf("failed to disable scale-to-zero: %w", err) + } + // ensure internal state fr.ffmpegErr = nil fr.exitCode = exitCodeInitValue @@ -133,6 +151,11 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { args, err := ffmpegArgs(fr.params, fr.outputPath) if err != nil { + _ = fr.stz.Enable(ctx) + fr.cmd = nil + close(fr.exited) + fr.mu.Unlock() + return err } log.Info(fmt.Sprintf("%s %s", fr.binaryPath, strings.Join(args, " "))) @@ -146,6 +169,12 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { fr.mu.Unlock() if err := cmd.Start(); err != nil { + _ = fr.stz.Enable(ctx) + fr.mu.Lock() + fr.ffmpegErr = err + fr.cmd = nil // reset cmd on failure to start so IsRecording() remains correct + close(fr.exited) + fr.mu.Unlock() return fmt.Errorf("failed to start ffmpeg process: %w", err) } @@ -164,19 +193,25 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { // Stop gracefully stops the recording using a multi-phase shutdown process. func (fr *FFmpegRecorder) Stop(ctx context.Context) error { - return fr.shutdownInPhases(ctx, []shutdownPhase{ + defer fr.stz.Enable(ctx) + err := fr.shutdownInPhases(ctx, []shutdownPhase{ {"wake_and_interrupt", []syscall.Signal{syscall.SIGCONT, syscall.SIGINT}, 5 * time.Second, "graceful stop"}, {"retry_interrupt", []syscall.Signal{syscall.SIGINT}, 3 * time.Second, "retry graceful stop"}, {"terminate", []syscall.Signal{syscall.SIGTERM}, 250 * time.Millisecond, "forceful termination"}, {"kill", []syscall.Signal{syscall.SIGKILL}, 100 * time.Millisecond, "immediate kill"}, }) + + return err } // ForceStop immediately terminates the recording process. func (fr *FFmpegRecorder) ForceStop(ctx context.Context) error { - return fr.shutdownInPhases(ctx, []shutdownPhase{ + defer fr.stz.Enable(ctx) + err := fr.shutdownInPhases(ctx, []shutdownPhase{ {"kill", []syscall.Signal{syscall.SIGKILL}, 100 * time.Millisecond, "immediate kill"}, }) + + return err } // IsRecording returns true if a recording is currently in progress. @@ -186,6 +221,17 @@ func (fr *FFmpegRecorder) IsRecording(ctx context.Context) bool { return fr.cmd != nil && fr.exitCode < exitCodeProcessDoneMinValue } +// Metadata is an incomplete snapshot of the recording metadata. +func (fr *FFmpegRecorder) Metadata() *RecordingMetadata { + fr.mu.Lock() + defer fr.mu.Unlock() + + return &RecordingMetadata{ + StartTime: fr.startTime, + EndTime: fr.endTime, + } +} + // Recording returns the recording file as an io.ReadCloser. func (fr *FFmpegRecorder) Recording(ctx context.Context) (io.ReadCloser, *RecordingMetadata, error) { file, err := os.Open(fr.outputPath) @@ -209,68 +255,66 @@ func (fr *FFmpegRecorder) Recording(ctx context.Context) (io.ReadCloser, *Record }, nil } -// ffmpegArgs generates platform-specific ffmpeg command line arguments. +// ffmpegArgs generates platform-specific ffmpeg command line arguments. Allegedly order matters. func ffmpegArgs(params FFmpegRecordingParams, outputPath string) ([]string, error) { + var args []string + + // Input options first switch runtime.GOOS { case "darwin": - return []string{ - // Input configuration - Use AVFoundation for macOS screen capture + args = []string{ + // Input options for AVFoundation "-f", "avfoundation", "-framerate", strconv.Itoa(*params.FrameRate), "-pixel_format", "nv12", + // Input file "-i", fmt.Sprintf("%d:none", *params.DisplayNum), // Screen capture, no audio - - // Video encoding - "-c:v", "libx264", - - // Timestamp handling for reliable playback - "-use_wallclock_as_timestamps", "1", // Use system time instead of input stream time - "-reset_timestamps", "1", // Reset timestamps to start from zero - "-avoid_negative_ts", "make_zero", // Convert negative timestamps to zero - - // Error handling - "-xerror", // Exit on any error - - // Output configuration for data safety - "-movflags", "+frag_keyframe+empty_moov", // Enable fragmented MP4 for data safety - "-frag_duration", "2000000", // 2-second fragments (in microseconds) - "-fs", fmt.Sprintf("%dM", *params.MaxSizeInMB), // File size limit - "-y", // Overwrite output file if it exists - outputPath, - }, nil + } case "linux": - return []string{ - // Input configuration - Use X11 screen capture for Linux + args = []string{ + // Input options for X11 "-f", "x11grab", "-framerate", strconv.Itoa(*params.FrameRate), + // Input file "-i", fmt.Sprintf(":%d", *params.DisplayNum), // X11 display - - // Video encoding - "-c:v", "libx264", - - // Timestamp handling for reliable playback - "-use_wallclock_as_timestamps", "1", // Use system time instead of input stream time - "-reset_timestamps", "1", // Reset timestamps to start from zero - "-avoid_negative_ts", "make_zero", // Convert negative timestamps to zero - - // Error handling - "-xerror", // Exit on any error - - // Output configuration for data safety - "-movflags", "+frag_keyframe+empty_moov", // Enable fragmented MP4 for data safety - "-frag_duration", "2000000", // 2-second fragments (in microseconds) - "-fs", fmt.Sprintf("%dM", *params.MaxSizeInMB), // File size limit - "-y", // Overwrite output file if it exists - outputPath, - }, nil + } default: return nil, fmt.Errorf("unsupported platform: %s", runtime.GOOS) } + + // Output options next + args = append(args, []string{ + // Video encoding + "-c:v", "libx264", + + // Timestamp handling for reliable playback + "-use_wallclock_as_timestamps", "1", // Use system time instead of input stream time + "-reset_timestamps", "1", // Reset timestamps to start from zero + "-avoid_negative_ts", "make_zero", // Convert negative timestamps to zero + + // Data safety + "-movflags", "+frag_keyframe+empty_moov", // Enable fragmented MP4 for data safety + "-frag_duration", "2000000", // 2-second fragments (in microseconds) + "-fs", fmt.Sprintf("%dM", *params.MaxSizeInMB), // File size limit + "-y", // Overwrite output file if it exists + }...) + + // Duration limit + if params.MaxDurationInSeconds != nil { + args = append(args, "-t", strconv.Itoa(*params.MaxDurationInSeconds)) + } + + // Output file + args = append(args, outputPath) + + return args, nil } // waitForCommand should be run in the background to wait for the ffmpeg process to complete and // update the internal state accordingly. func (fr *FFmpegRecorder) waitForCommand(ctx context.Context) { + defer fr.stz.Enable(ctx) + log := logger.FromContext(ctx) // wait for the process to complete and extract the exit code @@ -378,17 +422,16 @@ func (fm *FFmpegManager) GetRecorder(id string) (Recorder, bool) { return recorder, exists } -func (fm *FFmpegManager) ListActiveRecorders(ctx context.Context) []string { +func (fm *FFmpegManager) ListActiveRecorders(ctx context.Context) []Recorder { fm.mu.Lock() defer fm.mu.Unlock() - var active []string - for id, recorder := range fm.recorders { - if recorder.IsRecording(ctx) { - active = append(active, id) - } + recorders := make([]Recorder, 0, len(fm.recorders)) + for _, recorder := range fm.recorders { + recorders = append(recorders, recorder) } - return active + + return recorders } func (fm *FFmpegManager) DeregisterRecorder(ctx context.Context, recorder Recorder) error { diff --git a/server/lib/recorder/recorder.go b/server/lib/recorder/recorder.go index cbcc9140..f888b2d5 100644 --- a/server/lib/recorder/recorder.go +++ b/server/lib/recorder/recorder.go @@ -13,6 +13,7 @@ type Recorder interface { Stop(ctx context.Context) error ForceStop(ctx context.Context) error IsRecording(ctx context.Context) bool + Metadata() *RecordingMetadata Recording(ctx context.Context) (io.ReadCloser, *RecordingMetadata, error) // Returns the recording file as a ReadCloser } @@ -29,8 +30,8 @@ type RecordManager interface { // Returns the recorder and true if found, nil and false otherwise. GetRecorder(id string) (Recorder, bool) - // ListActiveRecorders returns a list of IDs for all currently recording recorders. - ListActiveRecorders(ctx context.Context) []string + // ListActiveRecorders returns a list of IDs for all registered recorders + ListActiveRecorders(ctx context.Context) []Recorder // DeregisterRecorder removes a recorder from the manager. DeregisterRecorder(ctx context.Context, recorder Recorder) error diff --git a/server/lib/scaletozero/scaletozero.go b/server/lib/scaletozero/scaletozero.go new file mode 100644 index 00000000..0ec86990 --- /dev/null +++ b/server/lib/scaletozero/scaletozero.go @@ -0,0 +1,86 @@ +package scaletozero + +import ( + "context" + "os" + "sync" + + "github.com/onkernel/kernel-images/server/lib/logger" +) + +// Unikraft scale-to-zero control file +// https://unikraft.cloud/docs/api/v1/instances/#scaletozero_app +const unikraftScaleToZeroFile = "/uk/libukp/scale_to_zero_disable" + +type Controller interface { + // Disable turns scale-to-zero off. + Disable(ctx context.Context) error + // Enable re-enables scale-to-zero after it has previously been disabled. + Enable(ctx context.Context) error +} + +type unikraftCloudController struct { + path string +} + +func NewUnikraftCloudController() Controller { + return &unikraftCloudController{path: unikraftScaleToZeroFile} +} + +func (c *unikraftCloudController) Disable(ctx context.Context) error { + return c.write(ctx, "+") +} + +func (c *unikraftCloudController) Enable(ctx context.Context) error { + return c.write(ctx, "-") +} + +func (c *unikraftCloudController) write(ctx context.Context, char string) error { + if _, err := os.Stat(c.path); err != nil { + if os.IsNotExist(err) { + return nil + } + logger.FromContext(ctx).Error("failed to stat scale-to-zero control file", "path", c.path, "err", err) + return err + } + + f, err := os.OpenFile(c.path, os.O_WRONLY|os.O_TRUNC, 0) + if err != nil { + logger.FromContext(ctx).Error("failed to open scale-to-zero control file", "path", c.path, "err", err) + return err + } + defer f.Close() + if _, err := f.Write([]byte(char)); err != nil { + logger.FromContext(ctx).Error("failed to write scale-to-zero control file", "path", c.path, "err", err) + return err + } + return nil +} + +type NoopController struct{} + +func NewNoopController() *NoopController { return &NoopController{} } + +func (NoopController) Disable(context.Context) error { return nil } +func (NoopController) Enable(context.Context) error { return nil } + +// Oncer wraps a Controller and ensures that Disable and Enable are called at most once. +type Oncer struct { + ctrl Controller + disableOnce sync.Once + enableOnce sync.Once + disableErr error + enableErr error +} + +func NewOncer(c Controller) *Oncer { return &Oncer{ctrl: c} } + +func (o *Oncer) Disable(ctx context.Context) error { + o.disableOnce.Do(func() { o.disableErr = o.ctrl.Disable(ctx) }) + return o.disableErr +} + +func (o *Oncer) Enable(ctx context.Context) error { + o.enableOnce.Do(func() { o.enableErr = o.ctrl.Enable(ctx) }) + return o.enableErr +} diff --git a/server/openapi.yaml b/server/openapi.yaml index fc1ebebb..8d284c61 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -5,7 +5,7 @@ info: paths: /recording/start: post: - summary: Start a screen recording. Only one recording can be active at a time. + summary: Start a screen recording. Only one recording per ID can be registered at a time. operationId: startRecording requestBody: required: false @@ -16,6 +16,8 @@ paths: responses: "201": description: Recording started + "400": + $ref: "#/components/responses/BadRequestError" "409": description: A recording is already in progress $ref: "#/components/responses/ConflictError" @@ -23,7 +25,7 @@ paths: $ref: "#/components/responses/InternalError" /recording/stop: post: - summary: Stop the current recording + summary: Stop the recording operationId: stopRecording requestBody: required: false @@ -41,10 +43,30 @@ paths: /recording/download: get: summary: Download the most recently recorded video file + parameters: + - name: id + in: query + description: Optional recorder identifier. When omitted, the server uses the default recorder. + schema: + type: string + pattern: "^[a-zA-Z0-9-]+$" operationId: downloadRecording responses: "200": description: Recording file + headers: + # Note: using a `format: date-time` here doesn't work as intended as the generated code + # calls a `fmt.Sprint` on the value when setting the header. time.String is a + # non-standard format that most parses will barf on, making everyone's life harder, so + # we're skipping the `format` in favor of an explicit description. + X-Recording-Started-At: + description: Timestamp of when the recording started. Guaranteed to be RFC3339. + schema: + type: string + X-Recording-Finished-At: + description: Timestamp of when the recording finished. Guaranteed to be RFC3339. + schema: + type: string content: video/mp4: schema: @@ -64,6 +86,21 @@ paths: $ref: "#/components/responses/NotFoundError" "500": $ref: "#/components/responses/InternalError" + /recording/list: + get: + summary: List all recorders + operationId: listRecorders + responses: + "200": + description: List of recorders + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/RecorderInfo" + "500": + $ref: "#/components/responses/InternalError" components: schemas: StartRecordingRequest: @@ -79,6 +116,14 @@ components: description: Recording framerate in fps (overrides server default) minimum: 1 maximum: 60 + maxDurationInSeconds: + type: integer + description: Maximum recording duration in seconds (overrides server default) + minimum: 1 + id: + type: string + description: Optional identifier for the recording session. Alphanumeric or hyphen. + pattern: "^[a-zA-Z0-9-]+$" additionalProperties: false StopRecordingRequest: type: object @@ -87,6 +132,10 @@ components: type: boolean description: Immediately stop without graceful shutdown. This may result in a corrupted video file. default: false + id: + type: string + description: Identifier of the recorder to stop. Alphanumeric or hyphen. + pattern: "^[a-zA-Z0-9-]+$" additionalProperties: false Error: type: object @@ -94,6 +143,24 @@ components: properties: message: type: string + RecorderInfo: + type: object + required: [id, isRecording] + properties: + id: + type: string + isRecording: + type: boolean + started_at: + type: string + format: date-time + nullable: true + description: Timestamp when recording started + finished_at: + type: string + format: date-time + nullable: true + description: Timestamp when recording finished responses: BadRequestError: description: Bad Request