Skip to content

Commit

Permalink
incusd/instance: Add support for VGA console screenshots
Browse files Browse the repository at this point in the history
Signed-off-by: Lucas Bremgartner <[email protected]>
  • Loading branch information
breml committed Nov 28, 2024
1 parent 664c04a commit 2eb0920
Show file tree
Hide file tree
Showing 8 changed files with 133 additions and 14 deletions.
69 changes: 60 additions & 9 deletions cmd/incusd/instance_console.go
Original file line number Diff line number Diff line change
Expand Up @@ -533,9 +533,10 @@ func instanceConsolePost(d *Daemon, r *http.Request) response.Response {

// swagger:operation GET /1.0/instances/{name}/console instances instance_console_get
//
// Get console log
// Get console output
//
// Gets the console log for the instance.
// Gets the console output for the instance either as text log or as vga
// screendump.
//
// ---
// produces:
Expand All @@ -546,14 +547,27 @@ func instanceConsolePost(d *Daemon, r *http.Request) response.Response {
// description: Project name
// type: string
// example: default
// - in: query
// name: type
// description: Console type
// type: string
// enum: [log, vga]
// default: log
// example: vga
// responses:
// "200":
// description: Raw console log
// description: |
// Console output either as raw console log or as vga screendump in PNG
// format depending on the `type` parameter provided with the request.
// content:
// application/octet-stream:
// schema:
// type: string
// example: some-text
// image/png:
// schema:
// type: string
// format: binary
// "400":
// $ref: "#/responses/BadRequest"
// "403":
Expand All @@ -576,6 +590,11 @@ func instanceConsoleLogGet(d *Daemon, r *http.Request) response.Response {
return response.SmartError(err)
}

consoleLogType := request.QueryParam(r, "type")
if consoleLogType != "" && consoleLogType != "log" && consoleLogType != "vga" {
return response.SmartError(fmt.Errorf("Invalid value for type parameter: %s", consoleLogType))
}

if internalInstance.IsSnapshot(name) {
return response.BadRequest(fmt.Errorf("Invalid instance name"))
}
Expand Down Expand Up @@ -649,16 +668,48 @@ func instanceConsoleLogGet(d *Daemon, r *http.Request) response.Response {
return response.SmartError(fmt.Errorf("Failed to cast inst to VM"))
}

logContents, err := v.ConsoleLog()
if err != nil {
return response.SmartError(err)
var headers map[string]string
if consoleLogType == "vga" {
screenshotFile, err := os.CreateTemp(v.Path(), "screenshot-*.png")
if err != nil {
return response.SmartError(fmt.Errorf("Couldn't create screenshot file: %w", err))
}

ent.Cleanup = func() {
_ = screenshotFile.Close()
_ = os.Remove(screenshotFile.Name())
}

err = v.ConsoleScreenshot(screenshotFile)
if err != nil {
return response.SmartError(err)
}

fileInfo, err := screenshotFile.Stat()
if err != nil {
return response.SmartError(fmt.Errorf("Couldn't stat screenshot file for filesize: %w", err))
}

headers = map[string]string{
"Content-Type": "image/png",
}

ent.File = screenshotFile
ent.FileSize = fileInfo.Size()
ent.Filename = screenshotFile.Name()
} else {
logContents, err := v.ConsoleLog()
if err != nil {
return response.SmartError(err)
}

ent.File = bytes.NewReader([]byte(logContents))
ent.FileSize = int64(len(logContents))
}

ent.File = bytes.NewReader([]byte(logContents))
ent.FileModified = time.Now()
ent.FileSize = int64(len(logContents))

return response.FileResponse(r, []response.FileResponseEntry{ent}, nil)
return response.FileResponse(r, []response.FileResponseEntry{ent}, headers)
}

return response.SmartError(fmt.Errorf("Unsupported instance type %q", inst.Type()))
Expand Down
4 changes: 4 additions & 0 deletions doc/api-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2660,3 +2660,7 @@ The following configuration options have been added:
## `storage_live_migration`

This adds support for virtual-machines live-migration between storage pools.

## `instance_console_screenshot`

This adds support to take screenshots of the current VGA console of a VM.
19 changes: 16 additions & 3 deletions doc/rest-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9297,19 +9297,32 @@ paths:
tags:
- instances
get:
description: Gets the console log for the instance.
description: |-
Gets the console output for the instance either as text log or as vga
screendump.
operationId: instance_console_get
parameters:
- description: Project name
example: default
in: query
name: project
type: string
- default: log
description: Console type
enum:
- log
- vga
example: vga
in: query
name: type
type: string
produces:
- application/json
responses:
"200":
description: Raw console log
description: |
Console output either as raw console log or as vga screendump in PNG
format depending on the `type` parameter provided with the request.
"400":
$ref: '#/responses/BadRequest'
"403":
Expand All @@ -9318,7 +9331,7 @@ paths:
$ref: '#/responses/NotFound'
"500":
$ref: '#/responses/InternalServerError'
summary: Get console log
summary: Get console output
tags:
- instances
post:
Expand Down
26 changes: 26 additions & 0 deletions internal/server/instance/drivers/driver_qemu.go
Original file line number Diff line number Diff line change
Expand Up @@ -9445,3 +9445,29 @@ func (d *qemu) consoleSwapSocketWithRB() error {

return monitor.ChardevChange("console", qmp.ChardevChangeInfo{Type: "ringbuf"})
}

// ConsoleScreenshot returns a screenshot of the current VGA console in PNG format.
func (d *qemu) ConsoleScreenshot(screenshotFile *os.File) error {
if !d.IsRunning() {
return fmt.Errorf("Instance is not running")
}

// Check if the agent is running.
monitor, err := qmp.Connect(d.monitorPath(), qemuSerialChardevName, d.getMonitorEventHandler(), d.QMPLogFilePath())
if err != nil {
return err
}

err = screenshotFile.Chown(int(d.state.OS.UnprivUID), -1)
if err != nil {
return fmt.Errorf("Failed to chown screenshot path: %w", err)
}

// Take the screenshot.
err = monitor.Screendump(screenshotFile.Name())
if err != nil {
return fmt.Errorf("Failed taking screenshot: %w", err)
}

return nil
}
20 changes: 20 additions & 0 deletions internal/server/instance/drivers/qmp/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -1274,3 +1274,23 @@ func (m *Monitor) ChardevChange(device string, info ChardevChangeInfo) error {

return fmt.Errorf("Unsupported chardev type %q", info.Type)
}

// Screendump takes a screenshot of the current VGA console.
// The screendump is stored to the filename provided as argument.
func (m *Monitor) Screendump(filename string) error {
var args struct {
Filename string `json:"filename"`
Device string `json:"device,omitempty"`
Head int `json:"head,omitempty"`
Format string `json:"format,omitempty"`
}

args.Filename = filename
args.Format = "png"

var queryResp struct {
Return struct{} `json:"return"`
}

return m.Run("screendump", args, &queryResp)
}
1 change: 1 addition & 0 deletions internal/server/instance/instance_interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ type VM interface {

AgentCertificate() *x509.Certificate
ConsoleLog() (string, error)
ConsoleScreenshot(screenshotFile *os.File) error
}

// CriuMigrationArgs arguments for CRIU migration.
Expand Down
7 changes: 5 additions & 2 deletions internal/server/response/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,6 @@ func (r *errorResponse) Render(w http.ResponseWriter) error {
}

err := json.NewEncoder(output).Encode(resp)

if err != nil {
return err
}
Expand Down Expand Up @@ -452,7 +451,11 @@ func (r *fileResponse) Render(w http.ResponseWriter) error {
rs = f
}

w.Header().Set("Content-Type", "application/octet-stream")
// Only set Content-Type header if it is still set to the default or not yet set at all.
if w.Header().Get("Content-Type") == "application/json" || w.Header().Get("Content-Type") == "" {
w.Header().Set("Content-Type", "application/octet-stream")
}

w.Header().Set("Content-Length", fmt.Sprintf("%d", sz))
w.Header().Set("Content-Disposition", fmt.Sprintf("inline;filename=%s", r.files[0].Filename))

Expand Down
1 change: 1 addition & 0 deletions internal/version/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ var APIExtensions = []string{
"custom_volume_refresh_exclude_older_snapshots",
"storage_initial_owner",
"storage_live_migration",
"instance_console_screenshot",
}

// APIExtensionsCount returns the number of available API extensions.
Expand Down

0 comments on commit 2eb0920

Please sign in to comment.