Compare commits

...
25 Commits
Author SHA1 Message Date
Nikolay Edigaryev 85dfa961c9 tart run: introduce --disk option (#156)
* tart run: introduce --disk option

* Document how to create disks using Disk Utility
2022-07-26 09:25:23 -04:00
Nikolay Edigaryev 3a74fc35e6 Introduce TART_HOME (#157) 2022-07-25 17:01:46 +03:00
Nikolay Edigaryev 7bf7f890c4 tart run: return exit status 2 when VM is already running (#155) 2022-07-21 07:53:47 -04:00
Nikolay Edigaryev 48cd4b47e4 Move full-fledged VNC support to --experimental-vnc (#154) 2022-07-21 12:37:43 +03:00
Nikolay Edigaryev c1dee4f9b2 Credentials: update Keychain entry if it already exists (#149) 2022-07-13 12:35:44 -04:00
Fedor Korotkov 116dc01f55 Document how to retrieve artifacts (#146) 2022-07-08 18:45:14 +03:00
Nikolay Edigaryev 52abb7589c OCI: support Basic authentication scheme (#145)
* OCI: support Basic authentication scheme

* .isValid → .isValid()

* tart login: make --username optional
2022-07-08 16:36:00 +03:00
Nikolay Edigaryev 4386192161 tart login: introduce --username and --password-stdin flags (#143) 2022-07-05 16:32:20 +03:00
Nikolay Edigaryev 85429cea0a Retrieve IP from DHCPD leases file instead of ARP cache (#141)
* Retrieve IP from DHCPD leases file instead of ARP cache

* Reference PLCache_read() from the retrieveRawLeases() parsing function
2022-06-30 17:58:52 +03:00
Nikolay Edigaryev 384abcd0bd OCI: make sure annotations are sorted (#138) 2022-06-27 16:25:52 +03:00
Fedor Korotkov 92529afa23 Handle tart run --no-graphics --vnc properly (#137)
Let's start a VNC server but not force open Screen Sharing if `--no-graphics` is also passed.
2022-06-24 22:54:41 +03:00
Nikolay Edigaryev c25364b2d4 Homebrew: depend on Softnet package (#136) 2022-06-21 14:55:51 -04:00
Nikolay Edigaryev e12f95878e Softnet: an alternative to built-in NAT with better isolation (#48)
* Softnet: an alternative to built-in NAT with better isolation

* Softnet: increase socketpair(2) socket buffer sizes to 1 MiB

* Pass VM's FD and MAC address to the Softnet

* Softnet: implement graceful shutdown

* Bring back the dispatchMain() and task cancellation

* tart pull: check for cancellation when pulling response body

* Don't dispatchMain() in withTaskCancellationHandler()

* Move VNC URL opening logic into VNCWrapper.open()
2022-06-21 19:17:02 +03:00
Fedor Korotkov 7efef28250 Allow VNC for Recovery mode (#134) 2022-06-20 20:08:31 +03:00
Nikolay Edigaryev 1e90752ded Full-fledged VNC support (#126) 2022-06-20 18:53:17 +03:00
Fedor Korotkov 2020324ef5 Fixed --no-graphics (#132)
Followup to #129 which broke CLI integration because `--no-graphics` option didn't wait for anything.
2022-06-20 10:28:10 -04:00
Nikolay Edigaryev b581db5be4 OCI: make annotations optional (#130)
* OCI: make annotations optional

* Don't initialize annotations by default
2022-06-17 10:53:52 -04:00
Fedor Korotkov 8ed2ce159f Install Go 2022-06-17 10:33:32 -04:00
Fedor Korotkov 89217c23a2 Fixes for Ventura (#129)
* Fixes for Ventura

Still a noob in SwiftUI and Swift concurrency, but it seems on Ventura a task group is not actually running on main or something. Either way I think this change simplifies things but launching a VM in a task and then just continuing with either VNC or built-in graphics.

* Check VM's state
2022-06-17 10:04:57 -04:00
Fedor Korotkov 0d633de04a Build Tart inside a Tart VM (#127) 2022-06-17 11:18:26 +03:00
Nikolay Edigaryev f59ef2722d OCI: store uncompressed disk size in manifest (#128) 2022-06-17 11:14:28 +03:00
Fedor Korotkov 7759c72a76 Don't use dispatchMain (#123) 2022-06-14 11:31:31 -04:00
Raymond 4cc8a9925a accept larger disk size (#122) 2022-06-10 09:05:57 -04:00
Nikolay Edigaryev b16bbf587a Registry: log upload location when encountering blob pushing error (#121) 2022-06-08 17:23:16 +03:00
Fedor Korotkov 022317bc17 Build Debug Binary (#117)
* Build Debug Binary

Xcode 14 Beta breaks something when building with a production configuration and Tart can't run a VM.

* Fixed path
2022-06-07 18:14:52 +03:00
33 changed files with 2769 additions and 230 deletions
+13 -5
View File
@@ -1,14 +1,16 @@
persistent_worker:
labels:
name: Mac-Mini-M1
task:
name: Test
name: Test on Ventura
persistent_worker:
labels:
name: Mac-Mini-M1
build_script: swift test
test_script: swift test
task:
name: Build
only_if: $CIRRUS_TAG == ''
macos_instance:
image: ghcr.io/cirruslabs/macos-monterey-xcode:14
build_script: swift build --product tart
sign_script: codesign --sign - --entitlements Resources/tart.entitlements --force .build/debug/tart
binary_artifacts:
@@ -17,7 +19,13 @@ task:
task:
name: Release
only_if: $CIRRUS_TAG != ''
macos_instance:
image: ghcr.io/cirruslabs/macos-monterey-xcode:14
env:
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
install_script: brew install go goreleaser/tap/goreleaser-pro
info_script:
- xcodebuild -version
- swift -version
release_script: goreleaser
+5 -3
View File
@@ -7,13 +7,13 @@ builds:
goarch:
- arm64
prebuilt:
path: .build/{{ .Arch }}-apple-macosx/release/tart
path: .build/{{ .Arch }}-apple-macosx/debug/tart
before:
hooks:
- .ci/set-version.sh
- swift build -c release --product tart
- codesign --sign - --entitlements Resources/tart.entitlements --force .build/arm64-apple-macosx/release/tart
- swift build -c debug --product tart
- codesign --sign - --entitlements Resources/tart.entitlements --force .build/arm64-apple-macosx/debug/tart
archives:
- id: binary
@@ -36,5 +36,7 @@ brews:
homepage: https://github.com/cirruslabs/tart
description: Run macOS VMs on Apple Silicon
skip_upload: auto
dependencies:
- "cirruslabs/cli/softnet"
custom_block: |
depends_on :macos => :monterey
+25
View File
@@ -55,6 +55,31 @@ config from above will just work in Cirrus CI and your tasks will be executed in
**Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
### Retrieving artifacts from within Tart VMs
In many cases there is a need to retrieve particular files or a folder from within a Tart virtual machine.
For example, the below `.cirrus.yml` configuration defines a single task that builds a `tart` binary and
exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tasks/#artifacts-instruction):
```yaml
task:
name: Build
macos_instance:
image: ghcr.io/cirruslabs/macos-monterey-xcode:latest
build_script: swift build --product tart
binary_artifacts:
path: .build/debug/tart
```
Running Cirrus CLI with `--artifacts-dir` will write defined `artifacts` to the provided local directory on the host:
```bash
cirrus run --artifacts-dir artifacts
```
Note that all retrieved artifacts will be prefixed with the associated task name and `artifacts` instruction name.
For the example above, `tart` binary will be saved to `$PWD/artifacts/Build/binary/.build/debug/tart`.
## Virtual Machine Management
### Creating from scratch
-119
View File
@@ -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])
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ struct Create: AsyncParsableCommand {
var fromIPSW: String?
@Option(help: ArgumentHelp("Disk size in Gb"))
var diskSize: UInt8 = 50
var diskSize: UInt16 = 50
func validate() throws {
if fromIPSW == nil {
+1 -1
View File
@@ -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
}
+24 -1
View File
@@ -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 != nil
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 let username = username {
user = username
password = readLine()!
} else {
(user, password) = try StdinCredentials.retrieve()
}
let credentialsProvider = DictionaryCredentialsProvider([
host: (user, password)
])
+99 -42
View File
@@ -24,58 +24,115 @@ struct Run: AsyncParsableCommand {
@Flag(help: ArgumentHelp(
"Use screen sharing instead of the built-in UI.",
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 Screen Sharing supports copy/paste, drag and drop, etc.\n"
+ "Note that Remote Login option should be enabled inside the VM."))
var vnc: Bool = false
@Flag(help: ArgumentHelp(
"Use Virtualization.Framework's VNC server instead of the build-in UI.",
discussion: "Useful since this type of VNC is available in recovery mode and in macOS installation.\n"
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
var vncExperimental: Bool = false
@Flag var withSoftnet: Bool = false
@Option(help: ArgumentHelp("""
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"disk.bin:ro\")
""", discussion: """
Learn how to create a disk image using Disk Utility here:
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
"""))
var disk: [String] = []
func validate() throws {
if vnc && vncExperimental {
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
}
}
@MainActor
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)
vm = try VM(
vmDir: vmDir,
withSoftnet: withSoftnet,
additionalDiskAttachments: additionalDiskAttachments()
)
await withThrowingTaskGroup(of: Void.self) { group in
let vncImpl: VNC? = try {
if vnc {
group.addTask(operation: {
do {
print("Waiting for the VM to boot...")
let resolvedIP = try await IP.resolveIP(vm!.config, secondsToWait: 60)
guard let ip = resolvedIP else {
throw IPNotFound()
}
let url = URL(string: "vnc://\(ip)")!
print("Opening \(url)")
NSWorkspace.shared.open(url)
} catch {
print("Failed to get an IP for screen sharing: \(error)")
}
})
}
group.addTask {
do {
try await vm!.run(recovery)
Foundation.exit(0)
} catch {
if error.localizedDescription.contains("Failed to lock auxiliary storage.") {
print("Virtual machine \"\(name)\" is already running!")
} else {
print(error)
}
Foundation.exit(1)
}
}
if noGraphics || vnc {
dispatchMain()
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
return ScreenSharingVNC(vmConfig: vmConfig)
} else if vncExperimental {
return FullFledgedVNC(virtualMachine: vm!.virtualMachine)
} else {
runUI()
return nil
}
}()
let task = Task {
do {
if let vncImpl = vncImpl {
let vncURL = try await vncImpl.waitForURL()
if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil {
print("VNC server is running at \(vncURL)")
} else {
print("Opening \(vncURL)...")
NSWorkspace.shared.open(vncURL)
}
}
try await vm!.run(recovery)
if let vncImpl = vncImpl {
try vncImpl.stop()
}
Foundation.exit(0)
} catch {
if error.localizedDescription.contains("Failed to lock auxiliary storage.") {
print("Virtual machine \"\(name)\" is already running!")
Foundation.exit(2)
}
print(error)
Foundation.exit(1)
}
}
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
sigintSrc.setEventHandler {
task.cancel()
}
sigintSrc.activate()
if noGraphics || vnc || vncExperimental {
dispatchMain()
} else {
runUI()
}
}
func additionalDiskAttachments() throws -> [VZDiskImageStorageDeviceAttachment] {
var result: [VZDiskImageStorageDeviceAttachment] = []
let readOnlySuffix = ":ro"
for rawDisk in disk {
if rawDisk.hasSuffix(readOnlySuffix) {
result.append(try VZDiskImageStorageDeviceAttachment(
url: URL(fileURLWithPath: String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count))),
readOnly: true
))
} else {
result.append(try VZDiskImageStorageDeviceAttachment(
url: URL(fileURLWithPath: rawDisk),
readOnly: false
))
}
}
return result
}
private func runUI() {
+1 -1
View File
@@ -17,7 +17,7 @@ struct Set: AsyncParsableCommand {
var display: VMDisplayConfig?
@Option(help: .hidden)
var diskSize: UInt8?
var diskSize: UInt16?
func run() async throws {
do {
+28 -4
View File
@@ -1,9 +1,33 @@
import Foundation
struct Config {
public static let tartHomeDir: URL = FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent(".tart", isDirectory: true)
let tartHomeDir: URL
let tartCacheDir: URL
public static let tartCacheDir: URL = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
init() {
var tartHomeDir: URL
if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] {
tartHomeDir = URL(fileURLWithPath: customTartHome)
} else {
tartHomeDir = FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent(".tart", isDirectory: true)
}
self.tartHomeDir = tartHomeDir
tartCacheDir = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
}
static func jsonEncoder() -> JSONEncoder {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
return encoder
}
static func jsonDecoder() -> JSONDecoder {
JSONDecoder()
}
}
@@ -34,21 +34,34 @@ class KeychainCredentialsProvider: CredentialsProvider {
}
func store(host: String, user: String, password: String) throws {
let passwordData = password.data(using: .utf8)
let attributes: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrAccount as String: user,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecValueData as String: password,
kSecValueData as String: passwordData,
kSecAttrLabel as String: "Tart Credentials",
]
let status = SecItemAdd(attributes as CFDictionary, nil)
switch status {
case errSecSuccess, errSecDuplicateItem:
case errSecSuccess:
return
case errSecDuplicateItem:
let status = SecItemUpdate(attributes as CFDictionary,
[kSecValueData as String : passwordData] as CFDictionary)
if status != errSecSuccess {
throw CredentialsProviderError.Failed(message: "Keychain failed to update item: \(status.explanation())")
}
default:
throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)")
throw CredentialsProviderError.Failed(message: "Keychain failed to add item: \(status.explanation())")
}
}
}
extension OSStatus {
func explanation() -> CFString {
SecCopyErrorMessageString(self, nil) ?? "Unknown status code \(self)." as CFString
}
}
@@ -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,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) {
+21
View File
@@ -0,0 +1,21 @@
import Foundation
protocol Authentication {
func header() -> (String, String)
func isValid() -> Bool
}
struct BasicAuthentication: Authentication {
let user: String
let password: String
func header() -> (String, String) {
let creds = Data("\(user):\(password)".utf8).base64EncodedString()
return ("Authorization", "Basic \(creds)")
}
func isValid() -> Bool {
true
}
}
+41 -1
View File
@@ -3,14 +3,54 @@ import Foundation
let ociManifestMediaType = "application/vnd.oci.image.manifest.v1+json"
let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
// Annotations
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
struct OCIManifest: Codable, Equatable {
var schemaVersion: Int = 2
var mediaType: String = ociManifestMediaType
var config: OCIManifestConfig
var layers: [OCIManifestLayer] = Array()
var annotations: Dictionary<String, String>?
init(config: OCIManifestConfig, layers: [OCIManifestLayer], uncompressedDiskSize: UInt64? = nil) {
self.config = config
self.layers = layers
if let uncompressedDiskSize = uncompressedDiskSize {
annotations = [
uncompressedDiskSizeAnnotation: String(uncompressedDiskSize)
]
}
}
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? {
guard let value = annotations?[uncompressedDiskSizeAnnotation] else {
return nil
}
return UInt64(value)
}
}
struct OCIConfig: Codable {
var architecture: String = "arm64"
var os: String = "darwin"
func toJSON() throws -> Data {
try Config.jsonEncoder().encode(self)
}
}
+27 -13
View File
@@ -25,7 +25,7 @@ extension HTTPClientResponse.Body {
}
}
struct TokenResponse: Decodable {
struct TokenResponse: Decodable, Authentication {
let defaultIssuedAt = Date()
let defaultExpiresIn = 60
@@ -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
@@ -65,10 +65,12 @@ struct TokenResponse: Decodable {
}
}
var isValid: Bool {
get {
Date() < tokenExpiresAt
}
func header() -> (String, String) {
("Authorization", "Bearer \(token)")
}
func isValid() -> Bool {
Date() < tokenExpiresAt
}
}
@@ -83,7 +85,7 @@ class Registry {
let namespace: String
let credentialsProvider: CredentialsProvider
var currentAuthToken: TokenResponse? = nil
var currentAuthToken: Authentication? = nil
init(urlComponents: URLComponents,
namespace: String,
@@ -116,7 +118,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 +141,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)
}
@@ -184,8 +186,8 @@ class Registry {
body: fromData)
if putResponse.status != .created {
let body = try await postResponse.body.readTextResponse()
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT)", code: putResponse.status.code,
details: body ?? "")
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
code: putResponse.status.code, details: body ?? "")
}
return digest
@@ -200,6 +202,8 @@ class Registry {
}
for try await part in response.body {
try Task.checkCancellation()
try handler(part)
}
}
@@ -243,7 +247,7 @@ class Registry {
}
// Invalidate token if it has expired
if currentAuthToken?.isValid == false {
if currentAuthToken?.isValid() == false {
currentAuthToken = nil
}
@@ -264,6 +268,15 @@ class Registry {
}
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
if wwwAuthenticate.scheme == "Basic" {
if let (user, password) = try credentialsProvider.retrieve(host: baseURL.host!) {
currentAuthToken = BasicAuthentication(user: user, password: password)
}
return
}
if wwwAuthenticate.scheme != "Bearer" {
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's authentication scheme "
+ "\"\(wwwAuthenticate.scheme)\" is unsupported, expected \"Bearer\" scheme")
@@ -314,7 +327,8 @@ class Registry {
var request = request
if let token = currentAuthToken {
request.headers.add(name: "Authorization", value: "Bearer \(token.token)")
let (name, value) = token.header()
request.headers.add(name: name, value: value)
}
return try await httpClient.execute(request, deadline: .distantFuture)
@@ -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
-1
View File
@@ -28,7 +28,6 @@ struct Root: AsyncParsableCommand {
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
sigintSrc.setEventHandler {
task.cancel()
Darwin.exit(1)
}
sigintSrc.activate()
+77
View File
@@ -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)")
}
}
}
+61 -12
View File
@@ -23,17 +23,27 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// VM's config
var config: VMConfig
init(vmDir: VMDirectory) throws {
var softnet: Softnet? = nil
init(vmDir: VMDirectory,
withSoftnet: Bool = false,
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
) throws {
let auxStorage = VZMacAuxiliaryStorage(contentsOf: vmDir.nvramURL)
name = vmDir.name
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, additionalDiskAttachments: additionalDiskAttachments)
virtualMachine = VZVirtualMachine(configuration: configuration)
super.init()
virtualMachine.delegate = self
}
@@ -46,7 +56,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
let ipswCacheFolder = Config.tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
let ipswCacheFolder = Config().tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
try FileManager.default.createDirectory(at: ipswCacheFolder, withIntermediateDirectories: true)
let expectedIPSWLocation = ipswCacheFolder.appendingPathComponent("\(image.buildVersion).ipsw", isDirectory: false)
@@ -77,8 +87,23 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
try data.write(to: expectedIPSWLocation, options: [.atomic])
return expectedIPSWLocation
}
var inFinalState: Bool {
get {
virtualMachine.state == VZVirtualMachine.State.stopped ||
virtualMachine.state == VZVirtualMachine.State.paused ||
virtualMachine.state == VZVirtualMachine.State.error
}
}
init(vmDir: VMDirectory, ipswURL: URL?, diskSizeGB: UInt8) async throws {
init(
vmDir: VMDirectory,
ipswURL: URL?,
diskSizeGB: UInt16,
withSoftnet: Bool = false,
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
) async throws {
let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW();
// Load the restore image and try to get the requirements
@@ -111,11 +136,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
try config.save(toURL: vmDir.configURL)
// 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, additionalDiskAttachments: additionalDiskAttachments)
virtualMachine = VZVirtualMachine(configuration: configuration)
super.init()
virtualMachine.delegate = self
// Run automated installation
@@ -134,6 +163,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
func run(_ recovery: Bool) async throws {
if let softnet = softnet {
try softnet.run()
}
try await virtualMachine.start(recovery)
await withTaskCancellationHandler(operation: {
@@ -149,9 +182,19 @@ 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,
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment]
) throws -> VZVirtualMachineConfiguration {
let configuration = VZVirtualMachineConfiguration()
// Boot loader
@@ -203,14 +246,20 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Networking
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
configuration.networkDevices = [vio]
// Storage
let attachment = try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)
let storage = VZVirtioBlockDeviceConfiguration(attachment: attachment)
configuration.storageDevices = [storage]
var attachments = [try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)]
attachments.append(contentsOf: additionalDiskAttachments)
configuration.storageDevices = attachments.map { VZVirtioBlockDeviceConfiguration(attachment: $0) }
// Entropy
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
+7 -3
View File
@@ -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 {
+5 -7
View File
@@ -117,8 +117,10 @@ extension VMDirectory {
// Read VM's compressed disk as chunks
// and sequentially upload them as blobs
let disk = try FileHandle(forReadingFrom: diskURL)
var diskReadBytes: UInt64 = 0
let compressingFilter = try InputFilter<Data>(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { _ in
let data = try disk.read(upToCount: Self.bufferSizeBytes)
diskReadBytes += UInt64(data?.count ?? 0)
progress.completedUnitCount += Int64(data?.count ?? 0)
@@ -137,16 +139,12 @@ 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),
layers: layers
layers: layers,
uncompressedDiskSize: diskReadBytes
)
// Manifest
+1 -1
View File
@@ -68,7 +68,7 @@ struct VMDirectory {
try newVMConfig.save(toURL: to.configURL)
}
func resizeDisk(_ sizeGB: UInt8) throws {
func resizeDisk(_ sizeGB: UInt16) throws {
if !FileManager.default.fileExists(atPath: diskURL.path) {
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
}
+1 -1
View File
@@ -1,7 +1,7 @@
import Foundation
class VMStorageLocal {
let baseURL: URL = Config.tartHomeDir.appendingPathComponent("vms", isDirectory: true)
let baseURL: URL = Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
private func vmURL(_ name: String) -> URL {
baseURL.appendingPathComponent(name, isDirectory: true)
+1 -1
View File
@@ -1,7 +1,7 @@
import Foundation
class VMStorageOCI {
let baseURL = Config.tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
let baseURL = Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
private func vmURL(_ name: RemoteName) -> URL {
baseURL.appendingRemoteName(name)
+38
View File
@@ -0,0 +1,38 @@
import Foundation
import Dynamic
import Virtualization
class FullFledgedVNC: VNC {
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 waitForURL() async throws -> URL {
while true {
// Port is 0 shortly after start(),
// but will be initialized later
if let port = vnc.port.asUInt16, port != 0 {
return URL(string: "vnc://:\(password)@127.0.0.1:\(port)")!
}
// Wait 50 ms.
try await Task.sleep(nanoseconds: 50_000_000)
}
}
func stop() throws {
vnc.stop()
}
deinit {
try? stop()
}
}
+25
View File
@@ -0,0 +1,25 @@
import Foundation
import Dynamic
import Virtualization
class ScreenSharingVNC: VNC {
let vmConfig: VMConfig
init(vmConfig: VMConfig) {
self.vmConfig = vmConfig
}
func waitForURL() async throws -> URL {
let ip = try await IP.resolveIP(vmConfig, secondsToWait: 60)
if let ip = ip {
return URL(string: "vnc://\(ip)")!
}
throw IPNotFound()
}
func stop() throws {
// nothing to do
}
}
+6
View File
@@ -0,0 +1,6 @@
import Foundation
protocol VNC {
func waitForURL() async throws -> URL
func stop() throws
}
@@ -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")!))
}
}
+1 -5
View File
@@ -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
+4 -4
View File
@@ -11,7 +11,7 @@ final class TokenResponseTests: XCTestCase {
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(60)
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
XCTAssertTrue(tokenResponse.isValid)
XCTAssertTrue(tokenResponse.isValid())
}
func testExpirationBasic() throws {
@@ -23,9 +23,9 @@ final class TokenResponseTests: XCTestCase {
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(2)
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
XCTAssertTrue(tokenResponse.isValid)
XCTAssertTrue(tokenResponse.isValid())
_ = XCTWaiter.wait(for: [expectation(description: "Wait 3 seconds for the token to become invalid")], timeout: 2)
XCTAssertFalse(tokenResponse.isValid)
XCTAssertFalse(tokenResponse.isValid())
}
func testExpirationWithIssuedAt() throws {
@@ -33,6 +33,6 @@ final class TokenResponseTests: XCTestCase {
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
XCTAssertEqual(Date(timeIntervalSince1970: 3600), tokenResponse.tokenExpiresAt)
XCTAssertFalse(tokenResponse.isValid)
XCTAssertFalse(tokenResponse.isValid())
}
}