Skip to content

Commit e0f4d09

Browse files
authored
Implement replay server (#17)
### Description We want to be able to capture the screen for headful use cases. The path we chose to do this is simply: 1. Create an http server we'll embed into the image. This server will expose a set of endpoints to start, stop, and download recordings 2. We'll use this interface externally as necessary to capture the screen For the moment we're encoding semantics that the server will have one "main" recording. I've implemented the rest of the backend without this restriction so it's easy to relax down the line. Under the hood this uses `ffmpeg` and expects that binary to be available. In tests running in docker and unikraft cloud the behavior around pause+resume was flaky. Instead of attempting to prevent data loss / corruption I took the strategy of minimizing the damage via ffmpeg fragmentation. I setup the server using what felt like ~sane defaults. The meat of this PR is in `ffmpeg.go`. I recommend reviewing this commit by commit ### Testing - [x] Added units tests where sensible - [x] Setup github actions for aforementioned units - [x] Performed a number of end to end tests both locally in docker + unikraft cloud using another locally running api to orchestrate the unikernels to replicate normal user workflows for persistent and non-persistent browsers
1 parent f9636f8 commit e0f4d09

File tree

19 files changed

+2634
-0
lines changed

19 files changed

+2634
-0
lines changed

.github/workflows/server-test.yaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: Test for the server/ directory
2+
3+
on:
4+
push:
5+
branches: ["main"]
6+
pull_request:
7+
branches: ["main"]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- name: Checkout code
15+
uses: actions/checkout@v4
16+
17+
- name: Set up Go
18+
uses: actions/setup-go@v5
19+
with:
20+
go-version-file: "server/go.mod"
21+
cache: true
22+
23+
- name: Run server Makefile tests
24+
run: make test
25+
working-directory: server

server/.gitignore

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Created by https://www.toptal.com/developers/gitignore/api/go
2+
# Edit at https://www.toptal.com/developers/gitignore?templates=go
3+
4+
### Go ###
5+
# If you prefer the allow list template instead of the deny list, see community template:
6+
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
7+
#
8+
# Binaries for programs and plugins
9+
*.exe
10+
*.exe~
11+
*.dll
12+
*.so
13+
*.dylib
14+
15+
# Test binary, built with `go test -c`
16+
*.test
17+
18+
# Output of the go coverage tool, specifically when used with LiteIDE
19+
*.out
20+
21+
# Dependency directories (remove the comment below to include it)
22+
# vendor/
23+
24+
# Go workspace file
25+
go.work
26+
27+
# End of https://www.toptal.com/developers/gitignore/api/go
28+
29+
.tmp/
30+
bin/
31+
recordings/
32+
33+
# downconverted openapi spec
34+
openapi-3.0.yaml

server/Makefile

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
SHELL := /bin/bash
2+
.PHONY: oapi-generate build dev test clean
3+
4+
BIN_DIR ?= $(CURDIR)/bin
5+
OAPI_CODEGEN ?= $(BIN_DIR)/oapi-codegen
6+
RECORDING_DIR ?= $(CURDIR)/recordings
7+
8+
$(BIN_DIR):
9+
mkdir -p $(BIN_DIR)
10+
11+
$(RECORDING_DIR):
12+
mkdir -p $(RECORDING_DIR)
13+
14+
$(OAPI_CODEGEN): | $(BIN_DIR)
15+
GOBIN=$(BIN_DIR) go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest
16+
17+
# Generate Go code from the OpenAPI spec
18+
# 1. Convert 3.1 → 3.0 since oapi-codegen doesn't support 3.1 yet (https://github.com/oapi-codegen/oapi-codegen/issues/373)
19+
# 2. Run oapi-codegen with our config
20+
# 3. go mod tidy to pull deps
21+
oapi-generate: $(OAPI_CODEGEN)
22+
pnpm i -g @apiture/openapi-down-convert
23+
openapi-down-convert --input openapi.yaml --output openapi-3.0.yaml
24+
$(OAPI_CODEGEN) -config ./oapi-codegen.yaml ./openapi-3.0.yaml
25+
go mod tidy
26+
27+
build: | $(BIN_DIR)
28+
go build -o $(BIN_DIR)/api ./cmd/api
29+
30+
dev: build $(RECORDING_DIR)
31+
OUTPUT_DIR=$(RECORDING_DIR) ./bin/api
32+
33+
test:
34+
go vet ./...
35+
go test -v -race ./...
36+
37+
clean:
38+
@rm -rf $(BIN_DIR)
39+
@rm -f openapi-3.0.yaml
40+
@echo "Clean complete"

server/README.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Kernel Images Server
2+
3+
A REST API server to start, stop, and download screen recordings.
4+
5+
## 🛠️ Prerequisites
6+
7+
### Required Software
8+
9+
- **Go 1.24.3+** - Programming language runtime
10+
- **FFmpeg** - Video recording engine
11+
- macOS: `brew install ffmpeg`
12+
- Linux: `sudo apt install ffmpeg` or `sudo yum install ffmpeg`
13+
- **Node.js/pnpm** - For OpenAPI code generation
14+
- `npm install -g pnpm`
15+
16+
### System Requirements
17+
18+
- **macOS**: Uses AVFoundation for screen capture
19+
- **Linux**: Uses X11 for screen capture
20+
- **Windows**: Not currently supported
21+
22+
## 🚀 Quick Start
23+
24+
### Running the Server
25+
26+
```bash
27+
make dev
28+
```
29+
30+
The server will start on port 10001 by default and log its configuration.
31+
32+
#### Example use
33+
34+
```bash
35+
# 1. Start a new recording
36+
curl http://localhost:10001/recording/start
37+
38+
# (recording in progress)
39+
40+
# 2. Stop recording and clean up resources
41+
curl http://localhost:10001/recording/stop
42+
43+
# 3. Download the recorded file
44+
curl http://localhost:10001/recording/download --output foo.mp4
45+
```
46+
47+
### ⚙️ Configuration
48+
49+
Configure the server using environment variables:
50+
51+
| Variable | Default | Description |
52+
| ------------- | ------- | --------------------------------- |
53+
| `PORT` | `10001` | HTTP server port |
54+
| `FRAME_RATE` | `10` | Default recording framerate (fps) |
55+
| `DISPLAY_NUM` | `1` | Display/screen number to capture |
56+
| `MAX_SIZE_MB` | `500` | Default maximum file size (MB) |
57+
| `OUTPUT_DIR` | `.` | Directory to save recordings |
58+
59+
#### Example Configuration
60+
61+
```bash
62+
export PORT=8080
63+
export FRAME_RATE=30
64+
export MAX_SIZE_MB=1000
65+
export OUTPUT_DIR=/tmp/recordings
66+
./bin/api
67+
```
68+
69+
### API Documentation
70+
71+
- **YAML Spec**: `GET /spec.yaml`
72+
- **JSON Spec**: `GET /spec.json`
73+
74+
## 🔧 Development
75+
76+
### Code Generation
77+
78+
The server uses OpenAPI code generation. After modifying `openapi.yaml`:
79+
80+
```bash
81+
make oapi-generate
82+
```
83+
84+
## 🧪 Testing
85+
86+
### Running Tests
87+
88+
```bash
89+
make test
90+
```

server/cmd/api/api/api.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package api
2+
3+
import (
4+
"context"
5+
6+
"github.com/onkernel/kernel-images/server/lib/logger"
7+
oapi "github.com/onkernel/kernel-images/server/lib/oapi"
8+
"github.com/onkernel/kernel-images/server/lib/recorder"
9+
)
10+
11+
// ApiService implements the API endpoints
12+
// It manages a single recording session and provides endpoints for starting, stopping, and downloading it
13+
type ApiService struct {
14+
mainRecorderID string // ID used for the primary recording session
15+
recordManager recorder.RecordManager
16+
factory recorder.FFmpegRecorderFactory
17+
}
18+
19+
func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFactory) *ApiService {
20+
return &ApiService{
21+
recordManager: recordManager,
22+
factory: factory,
23+
mainRecorderID: "main", // use a single recorder for now
24+
}
25+
}
26+
27+
func (s *ApiService) StartRecording(ctx context.Context, req oapi.StartRecordingRequestObject) (oapi.StartRecordingResponseObject, error) {
28+
log := logger.FromContext(ctx)
29+
30+
if rec, exists := s.recordManager.GetRecorder(s.mainRecorderID); exists && rec.IsRecording(ctx) {
31+
log.Error("attempted to start recording while one is already active")
32+
return oapi.StartRecording409JSONResponse{ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{Message: "recording already in progress"}}, nil
33+
}
34+
35+
var params recorder.FFmpegRecordingParams
36+
if req.Body != nil {
37+
params.FrameRate = req.Body.Framerate
38+
params.MaxSizeInMB = req.Body.MaxFileSizeInMB
39+
}
40+
41+
// Create, register, and start a new recorder
42+
rec, err := s.factory(s.mainRecorderID, params)
43+
if err != nil {
44+
log.Error("failed to create recorder", "err", err)
45+
return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to create recording"}}, nil
46+
}
47+
if err := s.recordManager.RegisterRecorder(ctx, rec); err != nil {
48+
log.Error("failed to register recorder", "err", err)
49+
return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to register recording"}}, nil
50+
}
51+
52+
if err := rec.Start(ctx); err != nil {
53+
log.Error("failed to start recording", "err", err)
54+
// ensure the recorder is deregistered if we fail to start
55+
defer s.recordManager.DeregisterRecorder(ctx, rec)
56+
return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to start recording"}}, nil
57+
}
58+
59+
return oapi.StartRecording201Response{}, nil
60+
}
61+
62+
func (s *ApiService) StopRecording(ctx context.Context, req oapi.StopRecordingRequestObject) (oapi.StopRecordingResponseObject, error) {
63+
log := logger.FromContext(ctx)
64+
65+
rec, exists := s.recordManager.GetRecorder(s.mainRecorderID)
66+
if !exists || !rec.IsRecording(ctx) {
67+
log.Warn("attempted to stop recording when none is active")
68+
return oapi.StopRecording400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "no active recording to stop"}}, nil
69+
}
70+
71+
// Check if force stop is requested
72+
forceStop := false
73+
if req.Body != nil && req.Body.ForceStop != nil {
74+
forceStop = *req.Body.ForceStop
75+
}
76+
77+
var err error
78+
if forceStop {
79+
log.Info("force stopping recording")
80+
err = rec.ForceStop(ctx)
81+
} else {
82+
log.Info("gracefully stopping recording")
83+
err = rec.Stop(ctx)
84+
}
85+
86+
if err != nil {
87+
log.Error("error occurred while stopping recording", "err", err, "force", forceStop)
88+
}
89+
90+
return oapi.StopRecording200Response{}, nil
91+
}
92+
93+
func (s *ApiService) DownloadRecording(ctx context.Context, req oapi.DownloadRecordingRequestObject) (oapi.DownloadRecordingResponseObject, error) {
94+
log := logger.FromContext(ctx)
95+
96+
// Get the recorder to access its output path
97+
rec, exists := s.recordManager.GetRecorder(s.mainRecorderID)
98+
if !exists {
99+
log.Error("attempted to download non-existent recording")
100+
return oapi.DownloadRecording404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "no recording found"}}, nil
101+
}
102+
103+
if rec.IsRecording(ctx) {
104+
log.Warn("attempted to download recording while is still in progress")
105+
return oapi.DownloadRecording400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "recording still in progress, please stop first"}}, nil
106+
}
107+
108+
out, meta, err := rec.Recording(ctx)
109+
if err != nil {
110+
log.Error("failed to get recording", "err", err)
111+
return oapi.DownloadRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to get recording"}}, nil
112+
}
113+
114+
log.Info("serving recording file for download", "size", meta.Size)
115+
return oapi.DownloadRecording200Videomp4Response{
116+
Body: out,
117+
ContentLength: meta.Size,
118+
}, nil
119+
}
120+
121+
func (s *ApiService) Shutdown(ctx context.Context) error {
122+
return s.recordManager.StopAll(ctx)
123+
}

0 commit comments

Comments
 (0)