Introduce "tart ip" command to retrieve a VM's IP address (#8)

* Introduce "tart ip" command to retrieve a VM's IP address

* VM running hint
This commit is contained in:
Nikolay Edigaryev 2022-02-23 01:11:33 +05:00 committed by GitHub
parent 2c7217f5a8
commit 7a67deab81
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 221 additions and 5 deletions

View File

@ -0,0 +1,116 @@
import Foundation
import Network
import Virtualization
struct ARPCommandFailedError: Error, CustomStringConvertible {
var terminationReason: Process.TerminationReason
var terminationStatus: Int32
var description: String {
var reason: String
switch terminationReason {
case .exit:
reason = "exit code \(terminationStatus)"
case .uncaughtSignal:
reason = "uncaught signal"
default:
reason = "unknown reason"
}
return "arp command failed: \(reason)"
}
}
struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible {
var explanation: String
var description: String {
"arp command yielded invalid output: \(explanation)"
}
}
struct ARPCacheInternalError: Error, CustomStringConvertible {
var explanation: String
var description: String {
"ARPCache internal error: \(explanation)"
}
}
struct ARPCache {
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
let process = Process.init()
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
process.arguments = ["-an"]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
process.standardInput = FileHandle.nullDevice
try process.run()
process.waitUntilExit()
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
throw ARPCommandFailedError(
terminationReason: process.terminationReason,
terminationStatus: process.terminationStatus)
}
guard let rawLines = try pipe.fileHandleForReading.readToEnd() else {
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
}
let lines = String(decoding: rawLines, as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: "\n")
// Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
let regex = try NSRegularExpression(pattern: #"^.* \((?<ip>.*)\) at (?<mac>.*) on (?<interface>.*) .*$"#)
for line in lines {
let nsLineRange = NSRange(line.startIndex..<line.endIndex, in: line)
guard let match = regex.firstMatch(in: line, range: nsLineRange) else {
throw ARPCommandYieldedInvalidOutputError(explanation: "unparseable entry \"\(line)\"")
}
let rawIP = try match.getCaptureGroup(name: "ip", for: line)
guard let ip = IPv4Address(rawIP) else {
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse IPv4 address \(rawIP)")
}
let rawMAC = try match.getCaptureGroup(name: "mac", for: line)
guard let mac = MACAddress(fromString: rawMAC) else {
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse MAC address \(rawMAC)")
}
let interface = try match.getCaptureGroup(name: "interface", for: line)
if bridgeOnly && !interface.starts(with: "bridge") {
continue
}
if macAddress == mac {
return ip
}
}
return nil
}
}
extension NSTextCheckingResult {
func getCaptureGroup(name: String, for string: String) throws -> String {
let nsRange = self.range(withName: name)
if nsRange.location == NSNotFound {
throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)")
}
guard let range = Range.init(nsRange, in: string) else {
throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range")
}
return String(string[range])
}
}

View File

@ -0,0 +1,21 @@
import Foundation
struct MACAddress: Equatable, CustomStringConvertible {
var mac: [UInt8] = Array(repeating: 0, count: 6)
init?(fromString: String) {
let components = fromString.components(separatedBy: ":")
if components.count != 6 {
return nil
}
for (index, component) in components.enumerated() {
mac[index] = UInt8(component, radix: 16)!
}
}
var description: String {
return String(format: "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
}
}

View File

@ -0,0 +1,36 @@
import ArgumentParser
import Foundation
import SystemConfiguration
struct IP: ParsableCommand {
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
@Argument(help: "VM name")
var name: String
func run() throws {
Task {
do {
let vmDir = try VMStorage().read(name)
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
let vmMacAddress = MACAddress(fromString: vmConfig.macAddress.string)!
guard let ip = try ARPCache.ResolveMACAddress(macAddress: vmMacAddress) else {
print("no IP address found, is your VM running?")
Foundation.exit(1)
}
print(ip)
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
}
}
dispatchMain()
}
}

View File

@ -3,5 +3,5 @@ import ArgumentParser
struct Root: ParsableCommand {
static var configuration = CommandConfiguration(
commandName: "tart",
subcommands: [Create.self, Run.self, List.self, Delete.self])
subcommands: [Create.self, Run.self, List.self, IP.self, Delete.self])
}

View File

@ -25,7 +25,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
auxStorage: auxStorage,
hardwareModel: vmConfig.hardwareModel,
cpuCount: vmConfig.cpuCount,
memorySize: vmConfig.memorySize
memorySize: vmConfig.memorySize,
macAddress: vmConfig.macAddress
)
self.virtualMachine = VZVirtualMachine(configuration: configuration)
@ -80,7 +81,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
auxStorage: auxStorage,
hardwareModel: requirements.hardwareModel,
cpuCount: self.vmConfig.cpuCount,
memorySize: self.vmConfig.memorySize
memorySize: self.vmConfig.memorySize,
macAddress: self.vmConfig.macAddress
)
self.virtualMachine = VZVirtualMachine(configuration: configuration)
@ -116,7 +118,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
auxStorage: VZMacAuxiliaryStorage,
hardwareModel: VZMacHardwareModel,
cpuCount: Int,
memorySize: UInt64
memorySize: UInt64,
macAddress: VZMACAddress
) throws -> VZVirtualMachineConfiguration {
let configuration = VZVirtualMachineConfiguration()
@ -153,6 +156,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Networking
let vio = VZVirtioNetworkDeviceConfiguration()
vio.attachment = VZNATNetworkDeviceAttachment()
vio.macAddress = macAddress
configuration.networkDevices = [vio]
// Storage

View File

@ -6,6 +6,7 @@ enum CodingKeys: String, CodingKey {
case hardwareModel
case cpuCount
case memorySize
case macAddress
}
struct VMConfig: Encodable, Decodable {
@ -14,12 +15,20 @@ struct VMConfig: Encodable, Decodable {
var hardwareModel: VZMacHardwareModel
var cpuCount: Int
var memorySize: UInt64
var macAddress: VZMACAddress
init(ecid: VZMacMachineIdentifier = VZMacMachineIdentifier(), hardwareModel: VZMacHardwareModel, cpuCount: Int, memorySize: UInt64) {
init(
ecid: VZMacMachineIdentifier = VZMacMachineIdentifier(),
hardwareModel: VZMacHardwareModel,
cpuCount: Int,
memorySize: UInt64,
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
) {
self.ecid = ecid
self.hardwareModel = hardwareModel
self.cpuCount = cpuCount
self.memorySize = memorySize
self.macAddress = macAddress
}
init(fromURL: URL) throws {
@ -63,6 +72,15 @@ struct VMConfig: Encodable, Decodable {
self.cpuCount = try container.decode(Int.self, forKey: .cpuCount)
self.memorySize = try container.decode(UInt64.self, forKey: .memorySize)
let encodedMacAddress = try container.decode(String.self, forKey: .macAddress)
guard let macAddress = VZMACAddress.init(string: encodedMacAddress) else {
throw DecodingError.dataCorruptedError(
forKey: .hardwareModel,
in: container,
debugDescription: "failed to initialize VZMacAddress using the provided value")
}
self.macAddress = macAddress
}
func encode(to encoder: Encoder) throws {
@ -73,5 +91,6 @@ struct VMConfig: Encodable, Decodable {
try container.encode(self.hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
try container.encode(self.cpuCount, forKey: .cpuCount)
try container.encode(self.memorySize, forKey: .memorySize)
try container.encode(self.macAddress.string, forKey: .macAddress)
}
}

View File

@ -11,6 +11,9 @@
440478FD27B1352C0028EFB8 /* Create.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440478FC27B1352C0028EFB8 /* Create.swift */; };
440478FF27B13D590028EFB8 /* Run.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440478FE27B13D590028EFB8 /* Run.swift */; };
4473E1E527A94E28000850C3 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4473E1E427A94E28000850C3 /* main.swift */; };
4484BA6B27BF1F270043A359 /* IP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4484BA6A27BF1F270043A359 /* IP.swift */; };
4484BA7027BF1F6D0043A359 /* ARPCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4484BA6E27BF1F6D0043A359 /* ARPCache.swift */; };
4484BA7127BF1F6D0043A359 /* MACAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4484BA6F27BF1F6D0043A359 /* MACAddress.swift */; };
44FDBB3427B4177C005A201B /* VMStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44FDBB3327B4177C005A201B /* VMStorage.swift */; };
44FDBB4227B43E6D005A201B /* VM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44FDBB4127B43E6D005A201B /* VM.swift */; };
44FDBB4427B4445E005A201B /* Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44FDBB4327B4445E005A201B /* Root.swift */; };
@ -37,6 +40,9 @@
440478FE27B13D590028EFB8 /* Run.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Run.swift; sourceTree = "<group>"; };
4473E1E127A94E27000850C3 /* tart */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = tart; sourceTree = BUILT_PRODUCTS_DIR; };
4473E1E427A94E28000850C3 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
4484BA6A27BF1F270043A359 /* IP.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IP.swift; sourceTree = "<group>"; };
4484BA6E27BF1F6D0043A359 /* ARPCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ARPCache.swift; sourceTree = "<group>"; };
4484BA6F27BF1F6D0043A359 /* MACAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MACAddress.swift; sourceTree = "<group>"; };
44FDBB3327B4177C005A201B /* VMStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMStorage.swift; sourceTree = "<group>"; };
44FDBB3927B43CCF005A201B /* tart-tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "tart-tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
44FDBB4127B43E6D005A201B /* VM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VM.swift; sourceTree = "<group>"; };
@ -91,9 +97,19 @@
path = Sources;
sourceTree = "<group>";
};
4484BA6D27BF1F6D0043A359 /* ARP */ = {
isa = PBXGroup;
children = (
4484BA6E27BF1F6D0043A359 /* ARPCache.swift */,
4484BA6F27BF1F6D0043A359 /* MACAddress.swift */,
);
path = ARP;
sourceTree = "<group>";
};
44FDBB4027B43DCB005A201B /* Commands */ = {
isa = PBXGroup;
children = (
4484BA6A27BF1F270043A359 /* IP.swift */,
440478FC27B1352C0028EFB8 /* Create.swift */,
440478FE27B13D590028EFB8 /* Run.swift */,
44FDBB4327B4445E005A201B /* Root.swift */,
@ -106,6 +122,7 @@
44FDBB4F27B6A4B6005A201B /* tart */ = {
isa = PBXGroup;
children = (
4484BA6D27BF1F6D0043A359 /* ARP */,
44FDBB4027B43DCB005A201B /* Commands */,
4473E1E427A94E28000850C3 /* main.swift */,
44FDBB3327B4177C005A201B /* VMStorage.swift */,
@ -214,10 +231,13 @@
440478FF27B13D590028EFB8 /* Run.swift in Sources */,
4473E1E527A94E28000850C3 /* main.swift in Sources */,
44FDBB4827B45EA1005A201B /* Delete.swift in Sources */,
4484BA6B27BF1F270043A359 /* IP.swift in Sources */,
4484BA7027BF1F6D0043A359 /* ARPCache.swift in Sources */,
44FDBB3427B4177C005A201B /* VMStorage.swift in Sources */,
44FDBB4627B44B35005A201B /* VMConfig.swift in Sources */,
44FDBB4227B43E6D005A201B /* VM.swift in Sources */,
44FDBB4C27B69515005A201B /* VMDirectory.swift in Sources */,
4484BA7127BF1F6D0043A359 /* MACAddress.swift in Sources */,
440478FD27B1352C0028EFB8 /* Create.swift in Sources */,
44FDBB4427B4445E005A201B /* Root.swift in Sources */,
44FDBB4A27B45F6F005A201B /* List.swift in Sources */,