Skip to content

Commit db8daa7

Browse files
committed
Add NoGraphics field to tart get command
1 parent e3ee2da commit db8daa7

File tree

3 files changed

+167
-1
lines changed

3 files changed

+167
-1
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,6 @@ dist/
2222

2323
# mkdocs-material
2424
site
25+
26+
# Python cache files
27+
*.pyc

Sources/tart/Commands/Get.swift

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import ArgumentParser
22
import Foundation
3+
import CoreGraphics
4+
import Darwin
35

46
fileprivate struct VMInfo: Encodable {
57
let OS: OS
@@ -11,6 +13,29 @@ fileprivate struct VMInfo: Encodable {
1113
let Display: String
1214
let Running: Bool
1315
let State: String
16+
let NoGraphics: Bool?
17+
18+
enum CodingKeys: String, CodingKey {
19+
case OS, CPU, Memory, Disk, DiskFormat, Size, Display, Running, State, NoGraphics
20+
}
21+
22+
func encode(to encoder: Encoder) throws {
23+
var container = encoder.container(keyedBy: CodingKeys.self)
24+
try container.encode(OS, forKey: .OS)
25+
try container.encode(CPU, forKey: .CPU)
26+
try container.encode(Memory, forKey: .Memory)
27+
try container.encode(Disk, forKey: .Disk)
28+
try container.encode(DiskFormat, forKey: .DiskFormat)
29+
try container.encode(Size, forKey: .Size)
30+
try container.encode(Display, forKey: .Display)
31+
try container.encode(Running, forKey: .Running)
32+
try container.encode(State, forKey: .State)
33+
if let noGraphics = NoGraphics {
34+
try container.encode(noGraphics, forKey: .NoGraphics)
35+
} else {
36+
try container.encodeNil(forKey: .NoGraphics)
37+
}
38+
}
1439
}
1540

1641
struct Get: AsyncParsableCommand {
@@ -27,7 +52,83 @@ struct Get: AsyncParsableCommand {
2752
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
2853
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
2954

30-
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)
55+
// Check if VM is running without graphics (no windows)
56+
var noGraphics: Bool? = nil
57+
if try vmDir.running() {
58+
let lock = try vmDir.lock()
59+
let pid = try lock.pid()
60+
if pid > 0 {
61+
noGraphics = try hasNoWindows(pid: pid)
62+
}
63+
}
64+
65+
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)
3166
print(format.renderSingle(info))
3267
}
68+
69+
private func hasNoWindows(pid: pid_t) throws -> Bool {
70+
// Check if the process and its children have any windows using Core Graphics Window Server
71+
// This is more reliable than checking command-line arguments since there are
72+
// multiple ways a VM might run without graphics (--no-graphics flag, CI environment, etc.)
73+
74+
// Get all PIDs to check (parent + children)
75+
var pidsToCheck = [pid]
76+
pidsToCheck.append(contentsOf: try getChildProcesses(of: pid))
77+
78+
// Get all window information from the window server
79+
guard let windowList = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else {
80+
// If we can't get window info, assume no graphics
81+
return true
82+
}
83+
84+
// Check if any window belongs to our process or its children
85+
for windowInfo in windowList {
86+
if let windowPID = windowInfo[kCGWindowOwnerPID as String] as? Int32,
87+
pidsToCheck.contains(windowPID) {
88+
// Found a window for this process tree, so it has graphics
89+
return false
90+
}
91+
}
92+
93+
// No windows found for this process tree
94+
return true
95+
}
96+
97+
private func getChildProcesses(of parentPID: pid_t) throws -> [pid_t] {
98+
var children: [pid_t] = []
99+
100+
// Use sysctl to get process information
101+
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0]
102+
var size: size_t = 0
103+
104+
// Get size needed
105+
if sysctl(&mib, 4, nil, &size, nil, 0) != 0 {
106+
// If we can't get process list, return empty array
107+
return children
108+
}
109+
110+
// Allocate memory and get process list
111+
let count = size / MemoryLayout<kinfo_proc>.size
112+
var procs = Array<kinfo_proc>(repeating: kinfo_proc(), count: count)
113+
114+
if sysctl(&mib, 4, &procs, &size, nil, 0) != 0 {
115+
// If we can't get process list, return empty array
116+
return children
117+
}
118+
119+
// Find direct children of the given parent PID
120+
for proc in procs {
121+
let ppid = proc.kp_eproc.e_ppid
122+
let pid = proc.kp_proc.p_pid
123+
if ppid == parentPID && pid > 0 {
124+
children.append(pid)
125+
// Recursively get children of children
126+
if let grandchildren = try? getChildProcesses(of: pid) {
127+
children.append(contentsOf: grandchildren)
128+
}
129+
}
130+
}
131+
132+
return children
133+
}
33134
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import json
2+
import os
3+
import subprocess
4+
import time
5+
import uuid
6+
7+
import pytest
8+
9+
10+
def check_json_format(tart, vm_name, running, noGraphics, expected):
11+
stdout, _ = tart.run(["get", vm_name, "--format", "json"])
12+
vm_info = json.loads(stdout)
13+
actual_running = vm_info["Running"]
14+
assert actual_running is running, f"Running is {actual_running}, expected {running}"
15+
assert vm_info.get("NoGraphics") is noGraphics, expected
16+
17+
def check_text_format(tart, vm_name, running, noGraphics, expected):
18+
stdout, _ = tart.run(["get", vm_name, "--format", "text"])
19+
assert "NoGraphics" in stdout, "NoGraphics field should be present in text output"
20+
21+
# Text format is tab-separated with headers in first line
22+
lines = stdout.strip().split('\n')
23+
if len(lines) >= 2:
24+
headers = lines[0].split()
25+
values = lines[1].split()
26+
info_dict = dict(zip(headers, values))
27+
else:
28+
info_dict = {}
29+
30+
# Convert "stopped" to false for Running field
31+
actual_running = info_dict.get("State") != "stopped"
32+
assert actual_running == running, f"Expected Running={running}, got State={actual_running}"
33+
assert info_dict.get("NoGraphics") == noGraphics, expected
34+
35+
@pytest.mark.skipif(os.environ.get("CI") == "true", reason="Normal graphics mode doesn't work in CI")
36+
def test_no_graphics_normal(tart):
37+
_test_no_graphics_impl(tart, [], False)
38+
39+
def test_no_graphics_disabled(tart):
40+
_test_no_graphics_impl(tart, ["--no-graphics"], True)
41+
42+
def _test_no_graphics_impl(tart, graphics_mode, expected_no_graphics):
43+
# Create a test VM (use Linux VM for faster tests)
44+
vm_name = f"integration-test-no-graphics-{uuid.uuid4()}"
45+
tart.run(["pull", "ghcr.io/cirruslabs/debian:latest"])
46+
tart.run(["clone", "ghcr.io/cirruslabs/debian:latest", vm_name])
47+
48+
# Test 1: VM not running - NoGraphics should be None in json format
49+
check_json_format(tart, vm_name, False, None, "NoGraphics should be None when VM is not running")
50+
51+
# Test 2: VM not running - NoGraphics should be NULL in text format
52+
check_text_format(tart, vm_name, False, "NULL", "NoGraphics should be NULL when VM is not running")
53+
54+
# Run VM with specified graphics mode
55+
tart_run_process = tart.run_async(["run"] + graphics_mode + [vm_name])
56+
time.sleep(3) # Give VM time to start
57+
58+
# Test 3: VM running - NoGraphics should be XX in json format
59+
check_json_format(tart, vm_name, True, expected_no_graphics, f"NoGraphics should be {expected_no_graphics} (JSON) when VM is running")
60+
61+
# Test 4: VM running - NoGraphics should be XX in text format
62+
check_text_format(tart, vm_name, True, str(expected_no_graphics).lower(), f"NoGraphics should be {expected_no_graphics} (TEXT) when VM is running")

0 commit comments

Comments
 (0)