diff --git a/cmd/incusd/instance_console.go b/cmd/incusd/instance_console.go index e97be8777a6..e16e8ab2d7e 100644 --- a/cmd/incusd/instance_console.go +++ b/cmd/incusd/instance_console.go @@ -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: @@ -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": @@ -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")) } @@ -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())) diff --git a/doc/api-extensions.md b/doc/api-extensions.md index 31ee653b62c..2c1f39b26b3 100644 --- a/doc/api-extensions.md +++ b/doc/api-extensions.md @@ -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. diff --git a/doc/rest-api.yaml b/doc/rest-api.yaml index 8d54bf9d50e..23771ad7324 100644 --- a/doc/rest-api.yaml +++ b/doc/rest-api.yaml @@ -9297,7 +9297,9 @@ 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 @@ -9305,11 +9307,22 @@ paths: 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": @@ -9318,7 +9331,7 @@ paths: $ref: '#/responses/NotFound' "500": $ref: '#/responses/InternalServerError' - summary: Get console log + summary: Get console output tags: - instances post: diff --git a/internal/server/instance/drivers/driver_qemu.go b/internal/server/instance/drivers/driver_qemu.go index 6fb27bdd753..f0419ece58d 100644 --- a/internal/server/instance/drivers/driver_qemu.go +++ b/internal/server/instance/drivers/driver_qemu.go @@ -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 +} diff --git a/internal/server/instance/drivers/qmp/commands.go b/internal/server/instance/drivers/qmp/commands.go index 2af767a2855..505391ab844 100644 --- a/internal/server/instance/drivers/qmp/commands.go +++ b/internal/server/instance/drivers/qmp/commands.go @@ -1274,3 +1274,22 @@ 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) +} diff --git a/internal/server/instance/instance_interface.go b/internal/server/instance/instance_interface.go index 6f7debfdb9e..f9a6daedd59 100644 --- a/internal/server/instance/instance_interface.go +++ b/internal/server/instance/instance_interface.go @@ -191,6 +191,7 @@ type VM interface { AgentCertificate() *x509.Certificate ConsoleLog() (string, error) + ConsoleScreenshot(screenshotFile *os.File) error } // CriuMigrationArgs arguments for CRIU migration. diff --git a/internal/server/response/response.go b/internal/server/response/response.go index 6ef394f45f8..7ac13cbec19 100644 --- a/internal/server/response/response.go +++ b/internal/server/response/response.go @@ -357,7 +357,6 @@ func (r *errorResponse) Render(w http.ResponseWriter) error { } err := json.NewEncoder(output).Encode(resp) - if err != nil { return err } @@ -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)) diff --git a/internal/version/api.go b/internal/version/api.go index a6124132029..602485ff27b 100644 --- a/internal/version/api.go +++ b/internal/version/api.go @@ -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.