Skip to content

Commit 29f08f8

Browse files
committed
Add VM stats
1 parent 496f3a3 commit 29f08f8

File tree

17 files changed

+331
-102
lines changed

17 files changed

+331
-102
lines changed

Sources/hostmgr/HostMgrCommand.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import libhostmgr
66
@main
77
struct Hostmgr: AsyncParsableCommand {
88

9-
private static var appVersion = "0.17.2"
9+
private static var appVersion = "0.51.0"
1010

1111
static var configuration = CommandConfiguration(
1212
abstract: "A utility for managing VM hosts",

Sources/hostmgr/VMCommand.swift

Lines changed: 14 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,41 +3,22 @@ import ArgumentParser
33

44
struct VMCommand: AsyncParsableCommand {
55

6-
static var universalCommands: [ParsableCommand.Type] = [
7-
VMDetailsCommand.self,
8-
VMExistsCommand.self,
9-
VMFetchCommand.self,
10-
VMListCommand.self,
11-
VMStartCommand.self,
12-
VMStopCommand.self,
13-
VMPublish.self,
14-
VMCloneCommand.self,
15-
]
16-
17-
static var appleSiliconCommands: [ParsableCommand.Type] {
18-
#if arch(arm64)
19-
return [
20-
VMCreateCommand.self,
21-
VMPackageCommand.self
22-
]
23-
#else
24-
return []
25-
#endif
26-
}
27-
28-
static var intelCommands: [ParsableCommand.Type] {
29-
#if arch(x86_64)
30-
return [
31-
VMCleanCommand.self
32-
]
33-
#else
34-
return []
35-
#endif
36-
}
37-
386
static let configuration = CommandConfiguration(
397
commandName: "vm",
408
abstract: "Allows working with VMs",
41-
subcommands: universalCommands + appleSiliconCommands + intelCommands
9+
subcommands: [
10+
VMCleanCommand.self,
11+
VMCloneCommand.self,
12+
VMCreateCommand.self,
13+
VMDetailsCommand.self,
14+
VMExistsCommand.self,
15+
VMFetchCommand.self,
16+
VMListCommand.self,
17+
VMPackageCommand.self,
18+
VMPublishCommand.self,
19+
VMStartCommand.self,
20+
VMStatsCommand.self,
21+
VMStopCommand.self,
22+
]
4223
)
4324
}

Sources/hostmgr/commands/vm/VMClean.swift

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ struct VMCleanCommand: AsyncParsableCommand {
66

77
static let configuration = CommandConfiguration(
88
commandName: "clean",
9-
abstract: "Clean up the VM environment prior to running another job"
9+
abstract: "Remove VMs that haven't been used recently"
1010
)
1111

1212
@DIInjected
@@ -15,9 +15,21 @@ struct VMCleanCommand: AsyncParsableCommand {
1515
enum CodingKeys: CodingKey {}
1616

1717
func run() async throws {
18-
try await vmManager.resetVMWorkingDirectory()
18+
let cutoff = Date(timeIntervalSinceNow: 90 * 24 * 60 * 60 * -1) // 90 days
1919

20-
// Clean up no-longer-needed local images
21-
try await vmManager.purgeUnusedImages()
20+
let unusedImages = try await vmManager.getVMImages(unusedSince: cutoff)
21+
22+
if unusedImages.isEmpty {
23+
Console.exit("No VMs to clean", style: .success)
24+
}
25+
26+
Console.info("Removing VMs not used since \(Format.date(cutoff, style: .short))")
27+
28+
for image in unusedImages {
29+
try await vmManager.removeVM(name: image.vm)
30+
Console.success("Removed \(image.vm)")
31+
}
32+
33+
Console.success("Finished cleaning VMs")
2234
}
2335
}

Sources/hostmgr/commands/vm/VMPublish.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Foundation
22
import ArgumentParser
33
import libhostmgr
44

5-
struct VMPublish: AsyncParsableCommand {
5+
struct VMPublishCommand: AsyncParsableCommand {
66

77
static let configuration = CommandConfiguration(
88
commandName: "publish",
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import Foundation
2+
import ArgumentParser
3+
import libhostmgr
4+
5+
struct VMStatsCommand: AsyncParsableCommand {
6+
7+
static let configuration = CommandConfiguration(
8+
commandName: "stats",
9+
abstract: "Show VM usage stats"
10+
)
11+
12+
@DIInjected
13+
var vmManager: any VMManager
14+
15+
enum CodingKeys: CodingKey {}
16+
17+
func run() async throws {
18+
let stats = try await vmManager.getVMUsageStats().grouped().asTable()
19+
20+
if stats.isEmpty {
21+
Console.error("No VM stats found")
22+
} else {
23+
Console.printTable(data: stats, columnTitles: ["VM Name", "Count"])
24+
}
25+
}
26+
}

Sources/hostmgr/commands/vm/VMStop.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,6 @@ struct VMStopCommand: AsyncParsableCommand {
3939
throw CleanExit.helpRequest()
4040
}
4141

42-
try await vmManager.stopVM(name: identifier)
42+
try await vmManager.stopVM(handle: identifier)
4343
}
4444
}

Sources/libhostmgr/CLI/Console.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,8 +282,8 @@ extension Console {
282282
return Console().printList(list, title: title)
283283
}
284284

285-
@discardableResult public static func printTable(data: Table, columnTitles: [String] = []) -> Self {
286-
return Console().printTable(data: data, columnTitles: columnTitles)
285+
@discardableResult public static func printTable(data: TableConvertable, columnTitles: [String] = []) -> Self {
286+
return Console().printTable(data: data.asTable(), columnTitles: columnTitles)
287287
}
288288

289289
public static func crash(_ error: HostmgrError) -> Never {
@@ -334,3 +334,13 @@ extension Console {
334334
public extension ProgressKind {
335335
static let installation = ProgressKind(rawValue: "installation")
336336
}
337+
338+
public protocol TableConvertable {
339+
func asTable() -> Console.Table
340+
}
341+
342+
extension Console.Table: TableConvertable {
343+
public func asTable() -> Console.Table {
344+
return self
345+
}
346+
}

Sources/libhostmgr/CLI/Format.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ public struct Format {
7676
return formatter.string(from: Date(), to: Date() + interval) ?? "a while"
7777
}
7878

79+
public static func date(_ date: Date, style: DateFormatter.Style) -> String {
80+
DateFormatter.localizedString(from: date, dateStyle: style, timeStyle: .none)
81+
}
82+
7983
public static func percentage(_ number: Decimal) -> String {
8084
return percentage(NSDecimalNumber(decimal: number))
8185
}

Sources/libhostmgr/Model/Paths.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ public struct Paths {
7474
.appendingPathComponent(storageDirectoryIdentifier)
7575
}
7676

77+
public static var vmUsageFile = stateRoot.appending(path: "vm-usage")
78+
7779
public static func toAppleSiliconVM(named name: String) -> URL {
7880
Paths.vmImageStorageDirectory.appendingPathComponent(name).appendingPathExtension("bundle")
7981
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import Foundation
2+
3+
public struct VMUsageRecord {
4+
public let vm: String
5+
public let date: Date
6+
7+
public func isAfter(date: Date) -> Bool {
8+
self.date.timeIntervalSince1970 > date.timeIntervalSince1970
9+
}
10+
}
11+
12+
public struct VMUsageAggregate {
13+
public let vm: String
14+
public let count: Int
15+
public let lastUsed: Date
16+
17+
public func merging(_ records: [VMUsageRecord]) -> VMUsageAggregate {
18+
records.reduce(self) { $0.merging($1) }
19+
}
20+
21+
public func merging(_ record: VMUsageRecord) -> VMUsageAggregate {
22+
let count = self.count + 1
23+
let date = record.isAfter(date: lastUsed) ? record.date : lastUsed
24+
return VMUsageAggregate(vm: vm, count: count, lastUsed: date)
25+
}
26+
27+
public static func from(record: VMUsageRecord) -> VMUsageAggregate {
28+
VMUsageAggregate(vm: record.vm, count: 1, lastUsed: record.date)
29+
}
30+
31+
public static func from(_ records: [VMUsageRecord]) -> VMUsageAggregate? {
32+
var mutableRecords = records
33+
34+
guard let initialrecord = mutableRecords.popLast() else {
35+
return nil
36+
}
37+
38+
return VMUsageAggregate.from(record: initialrecord).merging(mutableRecords)
39+
}
40+
}
41+
42+
extension [VMUsageRecord] {
43+
public func grouped() -> [VMUsageAggregate] {
44+
var aggregatedRecords = [String: VMUsageAggregate]()
45+
46+
for record in self {
47+
if let existingRecord = aggregatedRecords[record.vm] {
48+
aggregatedRecords[record.vm] = existingRecord.merging(record)
49+
} else {
50+
aggregatedRecords[record.vm] = VMUsageAggregate.from(record: record)
51+
}
52+
}
53+
54+
return aggregatedRecords.values.map { $0 }
55+
}
56+
57+
}
58+
59+
extension [VMUsageAggregate] {
60+
public func asTable() -> Console.Table {
61+
self.map { [$0.vm, String($0.count)] }
62+
}
63+
64+
func unused(since: Date) -> Self {
65+
self.filter {
66+
$0.lastUsed.timeIntervalSince1970 < since.timeIntervalSince1970
67+
}
68+
}
69+
}

0 commit comments

Comments
 (0)