Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for VGA console screenshots #1431

Merged
merged 8 commits into from
Nov 28, 2024
Merged
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
33 changes: 33 additions & 0 deletions internal/server/instance/drivers/driver_qemu.go
Original file line number Diff line number Diff line change
Expand Up @@ -1637,6 +1637,13 @@ func (d *qemu) start(stateful bool, op *operationlock.InstanceOperation) error {
}
}

// Change ownership of main instance directory.
err = os.Chown(d.Path(), int(d.state.OS.UnprivUID), -1)
if err != nil {
op.Done(err)
return fmt.Errorf("Failed to chown instance path: %w", err)
}

// Change ownership of config directory files so they are accessible to the
// unprivileged qemu process so that the 9p share can work.
//
Expand Down Expand Up @@ -9438,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