JSON output for get and list commands (#394)

* JSON output for `get` and `list` commands

In the light of the upcoming `1.0.0` release and stabilizing of the API, let's introduce some breaking changes for the good.

Removed all the `--cpu`, `--memory`, `--disk` and `--display` flags and replaced with a single `--json` flag for machine-readable output.

Added `--json` option to the `list` command to output a single JSON list. Notably removed `--quite` flag since it seemed unnecessary.

Fixes #297

* Added Size to `list` output

Fixes #379

* Added running state to `get`

Fixes #393

* Better signature

* Updated tests

* More test fixes
This commit is contained in:
Fedor Korotkov 2023-02-04 02:40:39 -05:00 committed by GitHub
parent ecc5de18be
commit f34aa5f072
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 99 additions and 55 deletions

View File

@ -116,6 +116,15 @@
"revision" : "da637c398c5d08896521b737f2868ddc2e7996ae",
"version" : "0.50.6"
}
},
{
"identity" : "texttable",
"kind" : "remoteSourceControl",
"location" : "https://github.com/cfilipov/TextTable",
"state" : {
"branch" : "master",
"revision" : "e03289289155b4e7aa565e32862f9cb42140596a"
}
}
],
"version" : 2

View File

@ -20,6 +20,7 @@ let package = Package(
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"),
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "7.31.3"),
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
],
targets: [
.executableTarget(name: "tart", dependencies: [
@ -32,7 +33,7 @@ let package = Package(
.product(name: "Antlr4Static", package: "Antlr4"),
.product(name: "Atomics", package: "swift-atomics"),
.product(name: "Sentry", package: "sentry-cocoa"),
.product(name: "TextTable", package: "TextTable"),
], exclude: [
"OCI/Reference/Makefile",
"OCI/Reference/Reference.g4",

View File

@ -1,52 +1,31 @@
import ArgumentParser
import Foundation
fileprivate struct VMInfo: Encodable {
let CPU: Int
let Memory: UInt64
let Disk: Int
let Display: String
let Running: Bool
}
struct Get: AsyncParsableCommand {
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
@Argument(help: "VM name.")
var name: String
@Flag(help: "Number of VM CPUs.")
var cpu: Bool = false
@Flag(help: "VM memory size in megabytes.")
var memory: Bool = false
@Flag(help: "Disk size in gigabytes.")
var diskSize: Bool = false
@Flag(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800.")
var display: Bool = false
func validate() throws {
if [cpu, memory, diskSize, display].filter({$0}).count > 1 {
throw ValidationError("Options --cpu, --memory, --disk-size and --display are mutually exclusive")
}
}
@Option(help: "Output format: text or json")
var format: Format = .text
func run() async throws {
let vmDir = try VMStorageLocal().open(name)
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
let diskSizeInGb = try vmDir.sizeBytes() / 1000 / 1000 / 1000
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
let diskSizeInGb = try vmDir.sizeGB()
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
let running = try PIDLock(lockURL: vmDir.configURL).pid() > 0
if cpu {
print(vmConfig.cpuCount)
} else if memory {
print(memorySizeInMb)
} else if diskSize {
print(diskSizeInGb)
} else if display {
print("\(vmConfig.display.width)x\(vmConfig.display.height)")
} else {
print(
"CPU\tMemory\tDisk\tDisplay\n" +
"\(vmConfig.cpuCount)\t" +
"\(memorySizeInMb) MB\t" +
"\(diskSizeInGb) GB\t" +
"\(vmConfig.display)"
)
}
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb, Display: vmConfig.display.description, Running: running)
print(format.renderSingle(info))
}
}

View File

@ -2,15 +2,24 @@ import ArgumentParser
import Dispatch
import SwiftUI
fileprivate struct VMInfo: Encodable {
let Source: String
let Name: String
let Size: Int
}
struct List: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "List created VMs")
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names."))
var quiet: Bool = false
@Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci)."))
var source: String?
@Option(help: "Output format: text or json")
var format: Format = .text
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names."))
var quiet: Bool = false
func validate() throws {
guard let source = source else {
return
@ -22,27 +31,28 @@ struct List: AsyncParsableCommand {
}
func run() async throws {
if !quiet {
print("Source\tName")
}
var infos: [VMInfo] = []
if source == nil || source == "local" {
displayTable("local", try VMStorageLocal().list())
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB())
})
}
if source == nil || source == "oci" {
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB())
})
}
if (quiet) {
for info in infos {
print(info.Name)
}
} else {
print(format.renderList(infos))
}
}
private func displayTable(_ source: String, _ vms: [(String, VMDirectory)]) {
for (name, _) in vms.sorted(by: { left, right in left.0 < right.0 }) {
if quiet {
print(name)
} else {
let source = source.padding(toLength: "Source".count, withPad: " ", startingAt: 0)
print("\(source)\t\(name)")
}
}
private func sortedInfos(_ infos: [VMInfo]) -> [VMInfo] {
infos.sorted(by: { left, right in left.Name < right.Name })
}
}

View File

@ -0,0 +1,41 @@
import ArgumentParser
import Foundation
import TextTable
enum Format: String, ExpressibleByArgument, CaseIterable {
case text, json
private(set) static var allValueStrings: [String] = Format.allCases.map { "\($0)"}
func renderSingle<T>(_ data: T) -> String where T: Encodable {
switch self {
case .text:
return renderList([data])
case .json:
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
return try! encoder.encode(data).asText()
}
}
func renderList<T>(_ data: Array<T>) -> String where T: Encodable {
switch self {
case .text:
if (data.count == 0) {
return ""
}
let table = TextTable<T> { (item: T) in
let mirroredObject = Mirror(reflecting: item)
return mirroredObject.children.enumerated().map { (_, element) in
let fieldName = element.label!
return Column(title: fieldName, value: element.value)
}
}
return table.string(for: data, style: Style.plain)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
case .json:
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
return try! encoder.encode(data).asText()
}
}
}

View File

@ -107,6 +107,10 @@ struct VMDirectory: Prunable {
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
}
func sizeGB() throws -> Int {
try sizeBytes() / 1000 / 1000 / 1000
}
func markExplicitlyPulled() {
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
}