mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-19 07:31:59 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4386192161 | ||
|
|
85429cea0a | ||
|
|
384abcd0bd | ||
|
|
92529afa23 | ||
|
|
c25364b2d4 | ||
|
|
e12f95878e | ||
|
|
7efef28250 | ||
|
|
1e90752ded | ||
|
|
2020324ef5 | ||
|
|
b581db5be4 |
@@ -36,5 +36,7 @@ brews:
|
|||||||
homepage: https://github.com/cirruslabs/tart
|
homepage: https://github.com/cirruslabs/tart
|
||||||
description: Run macOS VMs on Apple Silicon
|
description: Run macOS VMs on Apple Silicon
|
||||||
skip_upload: auto
|
skip_upload: auto
|
||||||
|
dependencies:
|
||||||
|
- "cirruslabs/cli/softnet"
|
||||||
custom_block: |
|
custom_block: |
|
||||||
depends_on :macos => :monterey
|
depends_on :macos => :monterey
|
||||||
|
|||||||
@@ -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)!
|
let vmMacAddress = MACAddress(fromString: config.macAddress.string)!
|
||||||
|
|
||||||
repeat {
|
repeat {
|
||||||
if let ip = try ARPCache.ResolveMACAddress(macAddress: vmMacAddress) {
|
if let ip = try Leases().resolveMACAddress(macAddress: vmMacAddress) {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,32 @@ struct Login: AsyncParsableCommand {
|
|||||||
@Argument(help: "host")
|
@Argument(help: "host")
|
||||||
var host: String
|
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 {
|
func run() async throws {
|
||||||
do {
|
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([
|
let credentialsProvider = DictionaryCredentialsProvider([
|
||||||
host: (user, password)
|
host: (user, password)
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -27,20 +27,40 @@ struct Run: AsyncParsableCommand {
|
|||||||
discussion: "Useful since VNC supports copy/paste, drag and drop, etc.\nNote that Remote Login option should be enabled inside the VM."))
|
discussion: "Useful since VNC supports copy/paste, drag and drop, etc.\nNote that Remote Login option should be enabled inside the VM."))
|
||||||
var vnc: Bool = false
|
var vnc: Bool = false
|
||||||
|
|
||||||
@MainActor
|
@Flag var withSoftnet: Bool = false
|
||||||
func run() async throws {
|
|
||||||
if recovery && vnc {
|
|
||||||
print("You can't run in recovery and use VNC!")
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
|
||||||
vm = try VM(vmDir: vmDir)
|
|
||||||
|
|
||||||
Task {
|
@MainActor
|
||||||
|
func run() async throws {
|
||||||
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
|
vm = try VM(vmDir: vmDir, withSoftnet: withSoftnet)
|
||||||
|
|
||||||
|
var vncWrapper: VNCWrapper?
|
||||||
|
|
||||||
|
if vnc {
|
||||||
|
vncWrapper = VNCWrapper(virtualMachine: vm!.virtualMachine)
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = Task {
|
||||||
do {
|
do {
|
||||||
|
if let vncWrapper = vncWrapper {
|
||||||
|
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)
|
try await vm!.run(recovery)
|
||||||
|
|
||||||
|
if let vncWrapper = vncWrapper {
|
||||||
|
try vncWrapper.stop()
|
||||||
|
}
|
||||||
|
|
||||||
Foundation.exit(0)
|
Foundation.exit(0)
|
||||||
} catch {
|
} catch {
|
||||||
if error.localizedDescription.contains("Failed to lock auxiliary storage.") {
|
if error.localizedDescription.contains("Failed to lock auxiliary storage.") {
|
||||||
@@ -52,23 +72,16 @@ struct Run: AsyncParsableCommand {
|
|||||||
Foundation.exit(1)
|
Foundation.exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if vnc {
|
|
||||||
do {
|
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
|
||||||
print("Waiting for the VM to boot...")
|
sigintSrc.setEventHandler {
|
||||||
let resolvedIP = try await IP.resolveIP(vm!.config, secondsToWait: 60)
|
task.cancel()
|
||||||
guard let ip = resolvedIP else {
|
}
|
||||||
throw IPNotFound()
|
sigintSrc.activate()
|
||||||
}
|
|
||||||
let url = URL(string: "vnc://\(ip)")!
|
if noGraphics || vnc {
|
||||||
print("Opening \(url)")
|
dispatchMain()
|
||||||
NSWorkspace.shared.open(url)
|
} else {
|
||||||
} catch {
|
|
||||||
print("Failed to get an IP for screen sharing: \(error)")
|
|
||||||
}
|
|
||||||
while !(vm?.inFinalState ?? false) {
|
|
||||||
try await Task.sleep(nanoseconds: 1_000_000)
|
|
||||||
}
|
|
||||||
} else if !noGraphics {
|
|
||||||
runUI()
|
runUI()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,16 @@ struct Config {
|
|||||||
.appendingPathComponent(".tart", isDirectory: true)
|
.appendingPathComponent(".tart", isDirectory: true)
|
||||||
|
|
||||||
public static let tartCacheDir: URL = tartHomeDir.appendingPathComponent("cache", 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
|
import Foundation
|
||||||
|
|
||||||
struct MACAddress: Equatable, CustomStringConvertible {
|
struct MACAddress: Equatable, Hashable, CustomStringConvertible {
|
||||||
var mac: [UInt8] = Array(repeating: 0, count: 6)
|
var mac: [UInt8] = Array(repeating: 0, count: 6)
|
||||||
|
|
||||||
init?(fromString: String) {
|
init?(fromString: String) {
|
||||||
@@ -11,7 +11,7 @@ struct OCIManifest: Codable, Equatable {
|
|||||||
var mediaType: String = ociManifestMediaType
|
var mediaType: String = ociManifestMediaType
|
||||||
var config: OCIManifestConfig
|
var config: OCIManifestConfig
|
||||||
var layers: [OCIManifestLayer] = Array()
|
var layers: [OCIManifestLayer] = Array()
|
||||||
var annotations: Dictionary<String, String> = Dictionary()
|
var annotations: Dictionary<String, String>?
|
||||||
|
|
||||||
init(config: OCIManifestConfig, layers: [OCIManifestLayer], uncompressedDiskSize: UInt64? = nil) {
|
init(config: OCIManifestConfig, layers: [OCIManifestLayer], uncompressedDiskSize: UInt64? = nil) {
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -24,12 +24,20 @@ 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 {
|
func digest() throws -> String {
|
||||||
try Digest.hash(JSONEncoder().encode(self))
|
try Digest.hash(toJSON())
|
||||||
}
|
}
|
||||||
|
|
||||||
func uncompressedDiskSize() -> UInt64? {
|
func uncompressedDiskSize() -> UInt64? {
|
||||||
guard let value = annotations[uncompressedDiskSizeAnnotation] else {
|
guard let value = annotations?[uncompressedDiskSizeAnnotation] else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 {
|
struct OCIManifestConfig: Codable, Equatable {
|
||||||
var mediaType: String = ociConfigMediaType
|
var mediaType: String = ociConfigMediaType
|
||||||
var size: Int
|
var size: Int
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ struct TokenResponse: Decodable {
|
|||||||
var issuedAt: Date?
|
var issuedAt: Date?
|
||||||
|
|
||||||
static func parse(fromData: Data) throws -> Self {
|
static func parse(fromData: Data) throws -> Self {
|
||||||
let decoder = JSONDecoder()
|
let decoder = Config.jsonDecoder()
|
||||||
|
|
||||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ class Registry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pushManifest(reference: String, manifest: OCIManifest) async throws -> String {
|
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)",
|
let response = try await endpointRequest(.PUT, "\(namespace)/manifests/\(reference)",
|
||||||
headers: ["Content-Type": manifest.mediaType],
|
headers: ["Content-Type": manifest.mediaType],
|
||||||
@@ -139,7 +139,7 @@ class Registry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let manifestData = try await response.body.readResponse()
|
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)
|
return (manifest, manifestData)
|
||||||
}
|
}
|
||||||
@@ -200,6 +200,8 @@ class Registry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for try await part in response.body {
|
for try await part in response.body {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
|
||||||
try handler(part)
|
try handler(part)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct PassphraseGenerator: Sequence {
|
||||||
|
func makeIterator() -> PassphraseIterator {
|
||||||
|
PassphraseIterator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PassphraseIterator: IteratorProtocol {
|
||||||
|
mutating func next() -> String? {
|
||||||
|
passphrases[Int(arc4random_uniform(UInt32(passphrases.count)))]
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,6 @@ struct Root: AsyncParsableCommand {
|
|||||||
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
|
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
|
||||||
sigintSrc.setEventHandler {
|
sigintSrc.setEventHandler {
|
||||||
task.cancel()
|
task.cancel()
|
||||||
Darwin.exit(1)
|
|
||||||
}
|
}
|
||||||
sigintSrc.activate()
|
sigintSrc.activate()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum SoftnetError: Error {
|
||||||
|
case InitializationFailed(why: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Softnet {
|
||||||
|
private let process = Process()
|
||||||
|
|
||||||
|
let vmFD: Int32
|
||||||
|
|
||||||
|
init(vmMACAddress: String) throws {
|
||||||
|
let binaryName = "softnet"
|
||||||
|
|
||||||
|
guard let executableURL = Self.resolveBinaryPath(binaryName) else {
|
||||||
|
throw SoftnetError.InitializationFailed(why: "\(binaryName) not found in PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
||||||
|
|
||||||
|
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
|
||||||
|
if ret != 0 {
|
||||||
|
throw SoftnetError.InitializationFailed(why: "socketpair() failed with exit code \(ret)")
|
||||||
|
}
|
||||||
|
|
||||||
|
vmFD = fds[0]
|
||||||
|
let softnetFD = fds[1]
|
||||||
|
|
||||||
|
try setSocketBuffers(vmFD, 1 * 1024 * 1024);
|
||||||
|
try setSocketBuffers(softnetFD, 1 * 1024 * 1024);
|
||||||
|
|
||||||
|
process.executableURL = executableURL
|
||||||
|
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress]
|
||||||
|
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() throws {
|
||||||
|
try process.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() throws {
|
||||||
|
process.interrupt()
|
||||||
|
process.waitUntilExit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveBinaryPath(_ name: String) -> URL? {
|
||||||
|
guard let path = ProcessInfo.processInfo.environment["PATH"] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for pathComponent in path.split(separator: ":") {
|
||||||
|
let url = URL(fileURLWithPath: String(pathComponent))
|
||||||
|
.appendingPathComponent(name, isDirectory: false)
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: url.path) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws {
|
||||||
|
var option_value = sizeBytes
|
||||||
|
let option_len = socklen_t(MemoryLayout<Int>.size)
|
||||||
|
|
||||||
|
var ret = setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &option_value, option_len)
|
||||||
|
if ret != 0 {
|
||||||
|
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_RCVBUF) returned \(ret)")
|
||||||
|
}
|
||||||
|
|
||||||
|
ret = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &option_value, option_len)
|
||||||
|
if ret != 0 {
|
||||||
|
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-8
@@ -23,17 +23,24 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
// VM's config
|
// VM's config
|
||||||
var config: VMConfig
|
var config: VMConfig
|
||||||
|
|
||||||
init(vmDir: VMDirectory) throws {
|
var softnet: Softnet? = nil
|
||||||
|
|
||||||
|
init(vmDir: VMDirectory, withSoftnet: Bool = false) throws {
|
||||||
let auxStorage = VZMacAuxiliaryStorage(contentsOf: vmDir.nvramURL)
|
let auxStorage = VZMacAuxiliaryStorage(contentsOf: vmDir.nvramURL)
|
||||||
|
|
||||||
name = vmDir.name
|
name = vmDir.name
|
||||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||||
|
|
||||||
let configuration = try VM.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config)
|
// Initialize the virtual machine and its configuration
|
||||||
|
if withSoftnet {
|
||||||
|
softnet = try Softnet(vmMACAddress: config.macAddress.string)
|
||||||
|
}
|
||||||
|
|
||||||
|
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config,
|
||||||
|
softnet: softnet)
|
||||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
virtualMachine.delegate = self
|
virtualMachine.delegate = self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +94,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init(vmDir: VMDirectory, ipswURL: URL?, diskSizeGB: UInt16) async throws {
|
init(vmDir: VMDirectory, ipswURL: URL?, diskSizeGB: UInt16, withSoftnet: Bool = false) async throws {
|
||||||
let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW();
|
let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW();
|
||||||
|
|
||||||
// Load the restore image and try to get the requirements
|
// Load the restore image and try to get the requirements
|
||||||
@@ -120,11 +127,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
try config.save(toURL: vmDir.configURL)
|
try config.save(toURL: vmDir.configURL)
|
||||||
|
|
||||||
// Initialize the virtual machine and its configuration
|
// Initialize the virtual machine and its configuration
|
||||||
let configuration = try VM.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config)
|
if withSoftnet {
|
||||||
|
softnet = try Softnet(vmMACAddress: config.macAddress.string)
|
||||||
|
}
|
||||||
|
|
||||||
|
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config,
|
||||||
|
softnet: softnet)
|
||||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
virtualMachine.delegate = self
|
virtualMachine.delegate = self
|
||||||
|
|
||||||
// Run automated installation
|
// Run automated installation
|
||||||
@@ -143,6 +154,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run(_ recovery: Bool) async throws {
|
func run(_ recovery: Bool) async throws {
|
||||||
|
if let softnet = softnet {
|
||||||
|
try softnet.run()
|
||||||
|
}
|
||||||
|
|
||||||
try await virtualMachine.start(recovery)
|
try await virtualMachine.start(recovery)
|
||||||
|
|
||||||
await withTaskCancellationHandler(operation: {
|
await withTaskCancellationHandler(operation: {
|
||||||
@@ -158,9 +173,18 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let softnet = softnet {
|
||||||
|
try softnet.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func craftConfiguration(diskURL: URL, auxStorage: VZMacAuxiliaryStorage, vmConfig: VMConfig) throws -> VZVirtualMachineConfiguration {
|
static func craftConfiguration(
|
||||||
|
diskURL: URL,
|
||||||
|
auxStorage: VZMacAuxiliaryStorage,
|
||||||
|
vmConfig: VMConfig,
|
||||||
|
softnet: Softnet? = nil
|
||||||
|
) throws -> VZVirtualMachineConfiguration {
|
||||||
let configuration = VZVirtualMachineConfiguration()
|
let configuration = VZVirtualMachineConfiguration()
|
||||||
|
|
||||||
// Boot loader
|
// Boot loader
|
||||||
@@ -212,7 +236,13 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
// Networking
|
// Networking
|
||||||
let vio = VZVirtioNetworkDeviceConfiguration()
|
let vio = VZVirtioNetworkDeviceConfiguration()
|
||||||
vio.attachment = VZNATNetworkDeviceAttachment()
|
|
||||||
|
if let softnet = softnet {
|
||||||
|
let fh = FileHandle.init(fileDescriptor: softnet.vmFD)
|
||||||
|
vio.attachment = VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
|
||||||
|
} else {
|
||||||
|
vio.attachment = VZNATNetworkDeviceAttachment()
|
||||||
|
}
|
||||||
vio.macAddress = vmConfig.macAddress
|
vio.macAddress = vmConfig.macAddress
|
||||||
configuration.networkDevices = [vio]
|
configuration.networkDevices = [vio]
|
||||||
|
|
||||||
|
|||||||
@@ -59,12 +59,16 @@ struct VMConfig: Codable {
|
|||||||
memorySize = memorySizeMin
|
memorySize = memorySizeMin
|
||||||
}
|
}
|
||||||
|
|
||||||
init(fromData: Data) throws {
|
init(fromJSON: Data) throws {
|
||||||
self = try JSONDecoder().decode(VMConfig.self, from: fromData)
|
self = try Config.jsonDecoder().decode(Self.self, from: fromJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(fromURL: URL) throws {
|
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 {
|
func save(toURL: URL) throws {
|
||||||
|
|||||||
@@ -139,12 +139,7 @@ extension VMDirectory {
|
|||||||
layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest))
|
layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest))
|
||||||
|
|
||||||
// Craft a stub OCI config for Docker Hub compatibility
|
// Craft a stub OCI config for Docker Hub compatibility
|
||||||
struct OCIConfig: Codable {
|
let ociConfigJSON = try OCIConfig().toJSON()
|
||||||
var architecture: String = "arm64"
|
|
||||||
var os: String = "darwin"
|
|
||||||
}
|
|
||||||
|
|
||||||
let ociConfigJSON = try JSONEncoder().encode(OCIConfig())
|
|
||||||
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON)
|
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON)
|
||||||
let manifest = OCIManifest(
|
let manifest = OCIManifest(
|
||||||
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Foundation
|
||||||
|
import Dynamic
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
class VNCWrapper {
|
||||||
|
let password: String
|
||||||
|
private let vnc: Dynamic
|
||||||
|
|
||||||
|
init(virtualMachine: VZVirtualMachine) {
|
||||||
|
password = Array(PassphraseGenerator().prefix(4)).joined(separator: "-")
|
||||||
|
let securityConfiguration = Dynamic._VZVNCAuthenticationSecurityConfiguration(password: password)
|
||||||
|
vnc = Dynamic._VZVNCServer(port: 0, queue: DispatchQueue.global(),
|
||||||
|
securityConfiguration: securityConfiguration)
|
||||||
|
vnc.virtualMachine = virtualMachine
|
||||||
|
vnc.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() throws {
|
||||||
|
vnc.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
try? stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForPort() async throws -> UInt16 {
|
||||||
|
while true {
|
||||||
|
// Port is 0 shortly after start(),
|
||||||
|
// but will be initialized later
|
||||||
|
if let port = vnc.port.asUInt16, port != 0 {
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait 50 ms.
|
||||||
|
try await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
func testPushPullManifest() async throws {
|
||||||
// Craft a basic config
|
// Craft a basic config
|
||||||
struct OCIConfig: Codable {
|
let configData = try OCIConfig().toJSON()
|
||||||
var architecture: String = "arm64"
|
|
||||||
var os: String = "darwin"
|
|
||||||
}
|
|
||||||
let configData = try JSONEncoder().encode(OCIConfig())
|
|
||||||
let configDigest = try await registry.pushBlob(fromData: configData)
|
let configDigest = try await registry.pushBlob(fromData: configData)
|
||||||
|
|
||||||
// Craft a basic layer
|
// Craft a basic layer
|
||||||
|
|||||||
Reference in New Issue
Block a user