Add NoGraphics field to tart get command

This commit is contained in:
Patrick Wyatt 2025-09-28 11:29:51 -07:00
parent e3ee2da2fd
commit db8daa7b3d
No known key found for this signature in database
GPG Key ID: 237DED3395984548
3 changed files with 167 additions and 1 deletions

3
.gitignore vendored
View File

@ -22,3 +22,6 @@ dist/
# mkdocs-material
site
# Python cache files
*.pyc

View File

@ -1,5 +1,7 @@
import ArgumentParser
import Foundation
import CoreGraphics
import Darwin
fileprivate struct VMInfo: Encodable {
let OS: OS
@ -11,6 +13,29 @@ fileprivate struct VMInfo: Encodable {
let Display: String
let Running: Bool
let State: String
let NoGraphics: Bool?
enum CodingKeys: String, CodingKey {
case OS, CPU, Memory, Disk, DiskFormat, Size, Display, Running, State, NoGraphics
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(OS, forKey: .OS)
try container.encode(CPU, forKey: .CPU)
try container.encode(Memory, forKey: .Memory)
try container.encode(Disk, forKey: .Disk)
try container.encode(DiskFormat, forKey: .DiskFormat)
try container.encode(Size, forKey: .Size)
try container.encode(Display, forKey: .Display)
try container.encode(Running, forKey: .Running)
try container.encode(State, forKey: .State)
if let noGraphics = NoGraphics {
try container.encode(noGraphics, forKey: .NoGraphics)
} else {
try container.encodeNil(forKey: .NoGraphics)
}
}
}
struct Get: AsyncParsableCommand {
@ -27,7 +52,83 @@ struct Get: AsyncParsableCommand {
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), DiskFormat: vmConfig.diskFormat.rawValue, Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue)
// Check if VM is running without graphics (no windows)
var noGraphics: Bool? = nil
if try vmDir.running() {
let lock = try vmDir.lock()
let pid = try lock.pid()
if pid > 0 {
noGraphics = try hasNoWindows(pid: pid)
}
}
let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), DiskFormat: vmConfig.diskFormat.rawValue, Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue, NoGraphics: noGraphics)
print(format.renderSingle(info))
}
private func hasNoWindows(pid: pid_t) throws -> Bool {
// Check if the process and its children have any windows using Core Graphics Window Server
// This is more reliable than checking command-line arguments since there are
// multiple ways a VM might run without graphics (--no-graphics flag, CI environment, etc.)
// Get all PIDs to check (parent + children)
var pidsToCheck = [pid]
pidsToCheck.append(contentsOf: try getChildProcesses(of: pid))
// Get all window information from the window server
guard let windowList = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else {
// If we can't get window info, assume no graphics
return true
}
// Check if any window belongs to our process or its children
for windowInfo in windowList {
if let windowPID = windowInfo[kCGWindowOwnerPID as String] as? Int32,
pidsToCheck.contains(windowPID) {
// Found a window for this process tree, so it has graphics
return false
}
}
// No windows found for this process tree
return true
}
private func getChildProcesses(of parentPID: pid_t) throws -> [pid_t] {
var children: [pid_t] = []
// Use sysctl to get process information
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0]
var size: size_t = 0
// Get size needed
if sysctl(&mib, 4, nil, &size, nil, 0) != 0 {
// If we can't get process list, return empty array
return children
}
// Allocate memory and get process list
let count = size / MemoryLayout<kinfo_proc>.size
var procs = Array<kinfo_proc>(repeating: kinfo_proc(), count: count)
if sysctl(&mib, 4, &procs, &size, nil, 0) != 0 {
// If we can't get process list, return empty array
return children
}
// Find direct children of the given parent PID
for proc in procs {
let ppid = proc.kp_eproc.e_ppid
let pid = proc.kp_proc.p_pid
if ppid == parentPID && pid > 0 {
children.append(pid)
// Recursively get children of children
if let grandchildren = try? getChildProcesses(of: pid) {
children.append(contentsOf: grandchildren)
}
}
}
return children
}
}

View File

@ -0,0 +1,62 @@
import json
import os
import subprocess
import time
import uuid
import pytest
def check_json_format(tart, vm_name, running, noGraphics, expected):
stdout, _ = tart.run(["get", vm_name, "--format", "json"])
vm_info = json.loads(stdout)
actual_running = vm_info["Running"]
assert actual_running is running, f"Running is {actual_running}, expected {running}"
assert vm_info.get("NoGraphics") is noGraphics, expected
def check_text_format(tart, vm_name, running, noGraphics, expected):
stdout, _ = tart.run(["get", vm_name, "--format", "text"])
assert "NoGraphics" in stdout, "NoGraphics field should be present in text output"
# Text format is tab-separated with headers in first line
lines = stdout.strip().split('\n')
if len(lines) >= 2:
headers = lines[0].split()
values = lines[1].split()
info_dict = dict(zip(headers, values))
else:
info_dict = {}
# Convert "stopped" to false for Running field
actual_running = info_dict.get("State") != "stopped"
assert actual_running == running, f"Expected Running={running}, got State={actual_running}"
assert info_dict.get("NoGraphics") == noGraphics, expected
@pytest.mark.skipif(os.environ.get("CI") == "true", reason="Normal graphics mode doesn't work in CI")
def test_no_graphics_normal(tart):
_test_no_graphics_impl(tart, [], False)
def test_no_graphics_disabled(tart):
_test_no_graphics_impl(tart, ["--no-graphics"], True)
def _test_no_graphics_impl(tart, graphics_mode, expected_no_graphics):
# Create a test VM (use Linux VM for faster tests)
vm_name = f"integration-test-no-graphics-{uuid.uuid4()}"
tart.run(["pull", "ghcr.io/cirruslabs/debian:latest"])
tart.run(["clone", "ghcr.io/cirruslabs/debian:latest", vm_name])
# Test 1: VM not running - NoGraphics should be None in json format
check_json_format(tart, vm_name, False, None, "NoGraphics should be None when VM is not running")
# Test 2: VM not running - NoGraphics should be NULL in text format
check_text_format(tart, vm_name, False, "NULL", "NoGraphics should be NULL when VM is not running")
# Run VM with specified graphics mode
tart_run_process = tart.run_async(["run"] + graphics_mode + [vm_name])
time.sleep(3) # Give VM time to start
# Test 3: VM running - NoGraphics should be XX in json format
check_json_format(tart, vm_name, True, expected_no_graphics, f"NoGraphics should be {expected_no_graphics} (JSON) when VM is running")
# Test 4: VM running - NoGraphics should be XX in text format
check_text_format(tart, vm_name, True, str(expected_no_graphics).lower(), f"NoGraphics should be {expected_no_graphics} (TEXT) when VM is running")