mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-12 08:55:34 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4386192161 | ||
|
|
85429cea0a | ||
|
|
384abcd0bd | ||
|
|
92529afa23 |
@@ -1,119 +0,0 @@
|
||||
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)
|
||||
if rawMAC == "(incomplete)" {
|
||||
continue
|
||||
}
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ struct IP: AsyncParsableCommand {
|
||||
let vmMacAddress = MACAddress(fromString: config.macAddress.string)!
|
||||
|
||||
repeat {
|
||||
if let ip = try ARPCache.ResolveMACAddress(macAddress: vmMacAddress) {
|
||||
if let ip = try Leases().resolveMACAddress(macAddress: vmMacAddress) {
|
||||
return ip
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,32 @@ struct Login: AsyncParsableCommand {
|
||||
@Argument(help: "host")
|
||||
var host: String
|
||||
|
||||
@Option(help: "username")
|
||||
var username: String
|
||||
|
||||
@Flag(help: "password-stdin")
|
||||
var passwordStdin: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
let usernameProvided = !username.isEmpty
|
||||
let passwordProvided = passwordStdin
|
||||
|
||||
if usernameProvided != passwordProvided {
|
||||
throw ValidationError("both --username and --password-stdin are required")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let (user, password) = try StdinCredentials.retrieve()
|
||||
var user: String
|
||||
var password: String
|
||||
|
||||
if !username.isEmpty {
|
||||
user = username
|
||||
password = readLine()!
|
||||
} else {
|
||||
(user, password) = try StdinCredentials.retrieve()
|
||||
}
|
||||
let credentialsProvider = DictionaryCredentialsProvider([
|
||||
host: (user, password)
|
||||
])
|
||||
|
||||
@@ -43,7 +43,16 @@ struct Run: AsyncParsableCommand {
|
||||
let task = Task {
|
||||
do {
|
||||
if let vncWrapper = vncWrapper {
|
||||
await vncWrapper.open()
|
||||
let port = try await vncWrapper.waitForPort()
|
||||
|
||||
let url = URL(string: "vnc://:\(vncWrapper.password)@127.0.0.1:\(port)")!
|
||||
|
||||
if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil {
|
||||
print("VNC server is running at \(url)")
|
||||
} else {
|
||||
print("Opening \(url)...")
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
try await vm!.run(recovery)
|
||||
|
||||
@@ -6,4 +6,16 @@ struct Config {
|
||||
.appendingPathComponent(".tart", isDirectory: true)
|
||||
|
||||
public static let tartCacheDir: URL = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
|
||||
|
||||
static func jsonEncoder() -> JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
|
||||
return encoder
|
||||
}
|
||||
|
||||
static func jsonDecoder() -> JSONDecoder {
|
||||
JSONDecoder()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import Network
|
||||
|
||||
struct Lease {
|
||||
var mac: MACAddress
|
||||
var ip: IPv4Address
|
||||
|
||||
init?(fromRawLease: [String : String]) {
|
||||
// Retrieve the required fields
|
||||
guard let hwAddress = fromRawLease["hw_address"] else { return nil }
|
||||
guard let ipAddress = fromRawLease["ip_address"] else { return nil }
|
||||
|
||||
// Parse MAC address
|
||||
let hwAddressSplits = hwAddress.split(separator: ",")
|
||||
if hwAddressSplits.count != 2 {
|
||||
return nil
|
||||
}
|
||||
if let hwAddressProto = Int(hwAddressSplits[0]), hwAddressProto != ARPHRD_ETHER {
|
||||
return nil
|
||||
}
|
||||
guard let mac = MACAddress(fromString: String(hwAddressSplits[1])) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse IP address
|
||||
guard let ip = IPv4Address(ipAddress) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.ip = ip
|
||||
self.mac = mac
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
enum LeasesError: Error {
|
||||
case UnexpectedFormat(name: String = "unexpected DHCPD leases file format", message: String, line: Int)
|
||||
case Truncated(name: String = "truncated DHCPD leases file")
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
|
||||
case .UnexpectedFormat(name: let name, message: let message, line: let line):
|
||||
return "\(name) on line \(line): \(message)"
|
||||
case .Truncated(name: let name):
|
||||
return "\(name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Leases {
|
||||
private let leases: [MACAddress : Lease]
|
||||
|
||||
convenience init() throws {
|
||||
try self.init(URL(fileURLWithPath: "/var/db/dhcpd_leases"))
|
||||
}
|
||||
|
||||
convenience init(_ fromURL: URL) throws {
|
||||
let fileContents = try String(contentsOf: fromURL, encoding: .utf8)
|
||||
|
||||
try self.init(fileContents)
|
||||
}
|
||||
|
||||
init(_ fromString: String) throws {
|
||||
var leases: [MACAddress : Lease] = Dictionary()
|
||||
|
||||
for lease in try Self.retrieveRawLeases(fromString).compactMap({ Lease(fromRawLease: $0) }) {
|
||||
leases[lease.mac] = lease
|
||||
}
|
||||
|
||||
self.leases = leases
|
||||
}
|
||||
|
||||
/// Parse leases from the host cache similarly to the PLCache_read() function found in Apple's Open Source releases.
|
||||
///
|
||||
/// [1]: https://github.com/apple-opensource/bootp/blob/master/bootplib/NICache.c#L285-L391
|
||||
private static func retrieveRawLeases(_ dhcpdLeasesContents: String) throws -> [[String : String]] {
|
||||
var rawLeases: [[String : String]] = Array()
|
||||
|
||||
enum State {
|
||||
case Nowhere
|
||||
case Start
|
||||
case Body
|
||||
case End
|
||||
}
|
||||
var state = State.Nowhere
|
||||
|
||||
var currentRawLease: [String : String] = Dictionary()
|
||||
|
||||
for (lineNumber, line) in dhcpdLeasesContents.split(separator: "\n").enumerated().map({ ($0 + 1, $1) }) {
|
||||
if line == "{" {
|
||||
// Handle lease block start
|
||||
if state != .Nowhere && state != .End {
|
||||
throw LeasesError.UnexpectedFormat(message: "unexpected lease block start ({)", line: lineNumber)
|
||||
}
|
||||
|
||||
state = .Start
|
||||
} else if line == "}" {
|
||||
// Handle lease block end
|
||||
if state != .Body {
|
||||
throw LeasesError.UnexpectedFormat(message: "unexpected lease block end (})", line: lineNumber)
|
||||
}
|
||||
|
||||
rawLeases.append(currentRawLease)
|
||||
currentRawLease = Dictionary()
|
||||
|
||||
state = .End
|
||||
} else {
|
||||
// Handle lease block contents
|
||||
let lineWithoutTabs = String(line.drop { $0 == " " || $0 == "\t"})
|
||||
|
||||
if lineWithoutTabs.isEmpty {
|
||||
continue
|
||||
}
|
||||
|
||||
let splits = lineWithoutTabs.split(separator: "=", maxSplits: 1)
|
||||
if splits.count != 2 {
|
||||
throw LeasesError.UnexpectedFormat(message: "key-value pair with only a key", line: lineNumber)
|
||||
}
|
||||
let (key, value) = (String(splits[0]), String(splits[1]))
|
||||
|
||||
currentRawLease[key] = value
|
||||
|
||||
state = .Body
|
||||
}
|
||||
}
|
||||
|
||||
if state == .Start || state == .Body {
|
||||
throw LeasesError.Truncated()
|
||||
}
|
||||
|
||||
return rawLeases
|
||||
}
|
||||
|
||||
func resolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
||||
leases[macAddress]?.ip
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct MACAddress: Equatable, CustomStringConvertible {
|
||||
struct MACAddress: Equatable, Hashable, CustomStringConvertible {
|
||||
var mac: [UInt8] = Array(repeating: 0, count: 6)
|
||||
|
||||
init?(fromString: String) {
|
||||
@@ -24,8 +24,16 @@ struct OCIManifest: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
init(fromJSON: Data) throws {
|
||||
self = try Config.jsonDecoder().decode(Self.self, from: fromJSON)
|
||||
}
|
||||
|
||||
func toJSON() throws -> Data {
|
||||
try Config.jsonEncoder().encode(self)
|
||||
}
|
||||
|
||||
func digest() throws -> String {
|
||||
try Digest.hash(JSONEncoder().encode(self))
|
||||
try Digest.hash(toJSON())
|
||||
}
|
||||
|
||||
func uncompressedDiskSize() -> UInt64? {
|
||||
@@ -37,6 +45,15 @@ struct OCIManifest: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
struct OCIConfig: Codable {
|
||||
var architecture: String = "arm64"
|
||||
var os: String = "darwin"
|
||||
|
||||
func toJSON() throws -> Data {
|
||||
try Config.jsonEncoder().encode(self)
|
||||
}
|
||||
}
|
||||
|
||||
struct OCIManifestConfig: Codable, Equatable {
|
||||
var mediaType: String = ociConfigMediaType
|
||||
var size: Int
|
||||
|
||||
@@ -34,7 +34,7 @@ struct TokenResponse: Decodable {
|
||||
var issuedAt: Date?
|
||||
|
||||
static func parse(fromData: Data) throws -> Self {
|
||||
let decoder = JSONDecoder()
|
||||
let decoder = Config.jsonDecoder()
|
||||
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
|
||||
@@ -116,7 +116,7 @@ class Registry {
|
||||
}
|
||||
|
||||
func pushManifest(reference: String, manifest: OCIManifest) async throws -> String {
|
||||
let manifestJSON = try JSONEncoder().encode(manifest)
|
||||
let manifestJSON = try manifest.toJSON()
|
||||
|
||||
let response = try await endpointRequest(.PUT, "\(namespace)/manifests/\(reference)",
|
||||
headers: ["Content-Type": manifest.mediaType],
|
||||
@@ -139,7 +139,7 @@ class Registry {
|
||||
}
|
||||
|
||||
let manifestData = try await response.body.readResponse()
|
||||
let manifest = try JSONDecoder().decode(OCIManifest.self, from: manifestData)
|
||||
let manifest = try OCIManifest(fromJSON: manifestData)
|
||||
|
||||
return (manifest, manifestData)
|
||||
}
|
||||
|
||||
@@ -59,12 +59,16 @@ struct VMConfig: Codable {
|
||||
memorySize = memorySizeMin
|
||||
}
|
||||
|
||||
init(fromData: Data) throws {
|
||||
self = try JSONDecoder().decode(VMConfig.self, from: fromData)
|
||||
init(fromJSON: Data) throws {
|
||||
self = try Config.jsonDecoder().decode(Self.self, from: fromJSON)
|
||||
}
|
||||
|
||||
init(fromURL: URL) throws {
|
||||
self = try Self(fromData: try Data(contentsOf: fromURL))
|
||||
self = try Self(fromJSON: try Data(contentsOf: fromURL))
|
||||
}
|
||||
|
||||
func toJSON() throws -> Data {
|
||||
try Config.jsonEncoder().encode(self)
|
||||
}
|
||||
|
||||
func save(toURL: URL) throws {
|
||||
|
||||
@@ -139,12 +139,7 @@ extension VMDirectory {
|
||||
layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest))
|
||||
|
||||
// Craft a stub OCI config for Docker Hub compatibility
|
||||
struct OCIConfig: Codable {
|
||||
var architecture: String = "arm64"
|
||||
var os: String = "darwin"
|
||||
}
|
||||
|
||||
let ociConfigJSON = try JSONEncoder().encode(OCIConfig())
|
||||
let ociConfigJSON = try OCIConfig().toJSON()
|
||||
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON)
|
||||
let manifest = OCIManifest(
|
||||
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
||||
|
||||
@@ -3,7 +3,7 @@ import Dynamic
|
||||
import Virtualization
|
||||
|
||||
class VNCWrapper {
|
||||
private let password: String
|
||||
let password: String
|
||||
private let vnc: Dynamic
|
||||
|
||||
init(virtualMachine: VZVirtualMachine) {
|
||||
@@ -15,21 +15,6 @@ class VNCWrapper {
|
||||
vnc.start()
|
||||
}
|
||||
|
||||
func open() async {
|
||||
do {
|
||||
let port = try await Self.waitForPort(vnc: vnc)
|
||||
|
||||
let url = URL(string: "vnc://:\(password)@127.0.0.1:\(port)")!
|
||||
print("Opening \(url)...")
|
||||
|
||||
if ProcessInfo.processInfo.environment["CI"] == nil {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
} catch {
|
||||
print("Failed to retrieve a VNC server port: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func stop() throws {
|
||||
vnc.stop()
|
||||
}
|
||||
@@ -38,7 +23,7 @@ class VNCWrapper {
|
||||
try? stop()
|
||||
}
|
||||
|
||||
private static func waitForPort(vnc: Dynamic) async throws -> UInt16 {
|
||||
func waitForPort() async throws -> UInt16 {
|
||||
while true {
|
||||
// Port is 0 shortly after start(),
|
||||
// but will be initialized later
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import XCTest
|
||||
import Network
|
||||
@testable import tart
|
||||
|
||||
final class MACAddressResolverTests: XCTestCase {
|
||||
func testSingleEntry() throws {
|
||||
let leases = try Leases("""
|
||||
{
|
||||
ip_address=1.2.3.4
|
||||
hw_address=1,00:11:22:33:44:55
|
||||
}
|
||||
""")
|
||||
|
||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
}
|
||||
|
||||
func testMultipleEntries() throws {
|
||||
let leases = try Leases("""
|
||||
{
|
||||
ip_address=1.2.3.4
|
||||
hw_address=1,00:11:22:33:44:55
|
||||
}
|
||||
{
|
||||
ip_address=5.6.7.8
|
||||
hw_address=1,AA:BB:CC:DD:EE:FF
|
||||
}
|
||||
""")
|
||||
|
||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
XCTAssertEqual(IPv4Address("5.6.7.8"),
|
||||
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
|
||||
}
|
||||
}
|
||||
@@ -62,11 +62,7 @@ final class RegistryTests: XCTestCase {
|
||||
|
||||
func testPushPullManifest() async throws {
|
||||
// Craft a basic config
|
||||
struct OCIConfig: Codable {
|
||||
var architecture: String = "arm64"
|
||||
var os: String = "darwin"
|
||||
}
|
||||
let configData = try JSONEncoder().encode(OCIConfig())
|
||||
let configData = try OCIConfig().toJSON()
|
||||
let configDigest = try await registry.pushBlob(fromData: configData)
|
||||
|
||||
// Craft a basic layer
|
||||
|
||||
Reference in New Issue
Block a user