mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-17 11:25:33 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12501370cc | ||
|
|
7d39f4cf68 | ||
|
|
8364f4ccac | ||
|
|
ef925e433d | ||
|
|
37af26a810 |
@@ -9,6 +9,7 @@ fileprivate struct VMInfo: Encodable {
|
|||||||
let DiskFormat: String
|
let DiskFormat: String
|
||||||
let Size: HumanReadableByteCount
|
let Size: HumanReadableByteCount
|
||||||
let Display: String
|
let Display: String
|
||||||
|
let MACAddress: String
|
||||||
let Running: Bool
|
let Running: Bool
|
||||||
let State: String
|
let State: String
|
||||||
}
|
}
|
||||||
@@ -38,6 +39,7 @@ struct Get: AsyncParsableCommand {
|
|||||||
String(format: "%.3f", Float($0) / 1000 / 1000 / 1000)
|
String(format: "%.3f", Float($0) / 1000 / 1000 / 1000)
|
||||||
},
|
},
|
||||||
Display: vmConfig.display.description,
|
Display: vmConfig.display.description,
|
||||||
|
MACAddress: vmConfig.macAddress.string,
|
||||||
Running: try vmDir.running(),
|
Running: try vmDir.running(),
|
||||||
State: try vmDir.state().rawValue
|
State: try vmDir.state().rawValue
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -187,6 +187,17 @@ struct Run: AsyncParsableCommand {
|
|||||||
""", valueName: "interface name"))
|
""", valueName: "interface name"))
|
||||||
var netBridged: [String] = []
|
var netBridged: [String] = []
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("Use a connected datagram Unix domain socket inherited from the parent process for networking (e.g. --net-fd=3)",
|
||||||
|
discussion: """
|
||||||
|
The socket carries raw Ethernet frames, one per datagram.
|
||||||
|
|
||||||
|
The simplest use-case is composing Tart and Softnet invocations that are orchestrated by another program, such as Orchard.
|
||||||
|
|
||||||
|
Another way to use this argument is to connect a VM to any other networking implementation such as gVisor or software-defined networking implementation.
|
||||||
|
""",
|
||||||
|
valueName: "file descriptor"))
|
||||||
|
var netFd: Int32?
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp("Use software networking provided by Softnet instead of the default shared (NAT) networking",
|
@Flag(help: ArgumentHelp("Use software networking provided by Softnet instead of the default shared (NAT) networking",
|
||||||
discussion: """
|
discussion: """
|
||||||
Softnet provides better network isolation and alleviates DHCP shortage on production systems. Tart invokes Softnet when this option is specified as a sub-process and communicates with it over socketpair(2).
|
Softnet provides better network isolation and alleviates DHCP shortage on production systems. Tart invokes Softnet when this option is specified as a sub-process and communicates with it over socketpair(2).
|
||||||
@@ -332,9 +343,10 @@ struct Run: AsyncParsableCommand {
|
|||||||
if netBridged.count > 0 { netFlags += 1 }
|
if netBridged.count > 0 { netFlags += 1 }
|
||||||
if netSoftnet { netFlags += 1 }
|
if netSoftnet { netFlags += 1 }
|
||||||
if netHost { netFlags += 1 }
|
if netHost { netFlags += 1 }
|
||||||
|
if netFd != nil { netFlags += 1 }
|
||||||
|
|
||||||
if netFlags > 1 {
|
if netFlags > 1 {
|
||||||
throw ValidationError("--net-bridged, --net-softnet and --net-host are mutually exclusive")
|
throw ValidationError("--net-bridged, --net-softnet, --net-host and --net-fd are mutually exclusive")
|
||||||
}
|
}
|
||||||
|
|
||||||
if graphics && noGraphics {
|
if graphics && noGraphics {
|
||||||
@@ -725,6 +737,10 @@ struct Run: AsyncParsableCommand {
|
|||||||
return NetworkBridged(interfaces: try netBridged.map { try findBridgedInterface($0) })
|
return NetworkBridged(interfaces: try netBridged.map { try findBridgedInterface($0) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let netFd = netFd {
|
||||||
|
return try NetworkFD(fd: netFd)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ struct Set: AsyncParsableCommand {
|
|||||||
@Flag(inversion: .prefixedNo, help: ArgumentHelp("Whether to automatically reconfigure the VM's display to fit the window"))
|
@Flag(inversion: .prefixedNo, help: ArgumentHelp("Whether to automatically reconfigure the VM's display to fit the window"))
|
||||||
var displayRefit: Bool? = nil
|
var displayRefit: Bool? = nil
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("MAC address to use."))
|
||||||
|
var mac: String?
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp("Generate a new random MAC address for the VM."))
|
@Flag(help: ArgumentHelp("Generate a new random MAC address for the VM."))
|
||||||
var randomMAC: Bool = false
|
var randomMAC: Bool = false
|
||||||
|
|
||||||
@@ -47,6 +50,10 @@ struct Set: AsyncParsableCommand {
|
|||||||
throw ValidationError("--disk is not supported for VMs with a stacked disk")
|
throw ValidationError("--disk is not supported for VMs with a stacked disk")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if randomMAC && mac != nil {
|
||||||
|
throw ValidationError("--random-mac and --mac are mutually exclusive")
|
||||||
|
}
|
||||||
|
|
||||||
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
|
|
||||||
if let cpu = cpu {
|
if let cpu = cpu {
|
||||||
@@ -67,7 +74,17 @@ struct Set: AsyncParsableCommand {
|
|||||||
vmConfig.display.unit = display.unit
|
vmConfig.display.unit = display.unit
|
||||||
}
|
}
|
||||||
|
|
||||||
vmConfig.displayRefit = displayRefit
|
if let displayRefit = displayRefit {
|
||||||
|
vmConfig.displayRefit = displayRefit
|
||||||
|
}
|
||||||
|
|
||||||
|
if let mac = mac {
|
||||||
|
if let macAddress = VZMACAddress.init(string: mac), macAddress.isUnicastAddress {
|
||||||
|
vmConfig.macAddress = macAddress
|
||||||
|
} else {
|
||||||
|
throw ValidationError("'\(mac)' is not a valid unicast MAC address")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if randomMAC {
|
if randomMAC {
|
||||||
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
||||||
|
|||||||
@@ -104,13 +104,13 @@ struct DockerAuthConfig: Codable {
|
|||||||
guard let data = Data(base64Encoded: authBase64) else {
|
guard let data = Data(base64Encoded: authBase64) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
guard let components = String(data: data, encoding: .utf8)?.components(separatedBy: ":") else {
|
guard let components = String(data: data, encoding: .utf8)?.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if components.count != 2 {
|
if components.count != 2 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return (components[0], components[1])
|
return (String(components[0]), String(components[1]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Darwin
|
||||||
|
import Foundation
|
||||||
|
import Semaphore
|
||||||
|
import System
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
class NetworkFD: Network {
|
||||||
|
private let attachment: VZFileHandleNetworkDeviceAttachment
|
||||||
|
|
||||||
|
init(fd: Int32) throws {
|
||||||
|
do {
|
||||||
|
try Softnet.validateControlFD(fd, type: SOCK_DGRAM)
|
||||||
|
} catch {
|
||||||
|
throw RuntimeError.Generic("--net-fd \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let flags = fcntl(fd, F_GETFD)
|
||||||
|
guard flags != -1 && fcntl(fd, F_SETFD, flags | FD_CLOEXEC) != -1 else {
|
||||||
|
throw RuntimeError.Generic("failed to set close-on-exec on --net-fd: \(Errno(rawValue: errno))")
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
||||||
|
attachment = VZFileHandleNetworkDeviceAttachment(fileHandle: fileHandle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||||
|
[attachment]
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(_ sema: AsyncSemaphore) throws {
|
||||||
|
// no-op, only used for Softnet
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() async throws {
|
||||||
|
// no-op, only used for Softnet
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,11 @@ class Softnet: Network {
|
|||||||
}
|
}
|
||||||
|
|
||||||
controlFileHandle = FileHandle(fileDescriptor: controlFD, closeOnDealloc: true)
|
controlFileHandle = FileHandle(fileDescriptor: controlFD, closeOnDealloc: true)
|
||||||
try Self.validateControlFD(controlFD)
|
do {
|
||||||
|
try Self.validateControlFD(controlFD)
|
||||||
|
} catch {
|
||||||
|
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
||||||
@@ -50,20 +54,16 @@ class Softnet: Network {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func validateControlFD(_ fd: Int32) throws {
|
static func validateControlFD(_ fd: Int32, type: Int32 = SOCK_STREAM) throws {
|
||||||
guard fd > STDERR_FILENO else {
|
|
||||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be greater than 2")
|
|
||||||
}
|
|
||||||
|
|
||||||
var socketType: Int32 = 0
|
var socketType: Int32 = 0
|
||||||
var socketTypeLength = socklen_t(MemoryLayout<Int32>.size)
|
var socketTypeLength = socklen_t(MemoryLayout<Int32>.size)
|
||||||
guard getsockopt(fd, SOL_SOCKET, SO_TYPE, &socketType, &socketTypeLength) == 0 else {
|
guard getsockopt(fd, SOL_SOCKET, SO_TYPE, &socketType, &socketTypeLength) == 0 else {
|
||||||
let details = Errno(rawValue: CInt(errno))
|
let details = Errno(rawValue: CInt(errno))
|
||||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor is not a socket: \(details)")
|
throw RuntimeError.Generic("is not a socket: \(details)")
|
||||||
}
|
}
|
||||||
|
|
||||||
guard socketType == SOCK_STREAM else {
|
guard socketType == type else {
|
||||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be a Unix stream socket")
|
throw RuntimeError.Generic("must be a \(type == SOCK_STREAM ? "stream" : "datagram") socket")
|
||||||
}
|
}
|
||||||
|
|
||||||
var peerAddress = sockaddr_storage()
|
var peerAddress = sockaddr_storage()
|
||||||
@@ -75,11 +75,11 @@ class Softnet: Network {
|
|||||||
}
|
}
|
||||||
guard result == 0 else {
|
guard result == 0 else {
|
||||||
let details = Errno(rawValue: CInt(errno))
|
let details = Errno(rawValue: CInt(errno))
|
||||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor is not connected: \(details)")
|
throw RuntimeError.Generic("is not connected: \(details)")
|
||||||
}
|
}
|
||||||
|
|
||||||
guard peerAddress.ss_family == sa_family_t(AF_UNIX) else {
|
guard peerAddress.ss_family == sa_family_t(AF_UNIX) else {
|
||||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be a Unix stream socket")
|
throw RuntimeError.Generic("must be a Unix socket")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,12 +69,15 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
let dateFormatter = ISO8601DateFormatter()
|
let dateFormatter = ISO8601DateFormatter()
|
||||||
dateFormatter.formatOptions = [.withInternetDateTime]
|
dateFormatter.formatOptions = [.withInternetDateTime]
|
||||||
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
let fractionalDateFormatter = ISO8601DateFormatter()
|
||||||
|
fractionalDateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
fractionalDateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
|
||||||
decoder.dateDecodingStrategy = .custom { decoder in
|
decoder.dateDecodingStrategy = .custom { decoder in
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
let dateString = try container.decode(String.self)
|
let dateString = try container.decode(String.self)
|
||||||
|
|
||||||
return dateFormatter.date(from: dateString) ?? Date()
|
return dateFormatter.date(from: dateString) ?? fractionalDateFormatter.date(from: dateString) ?? Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
var response = try decoder.decode(TokenResponse.self, from: fromData)
|
var response = try decoder.decode(TokenResponse.self, from: fromData)
|
||||||
|
|||||||
@@ -206,6 +206,22 @@ final class CommandBehaviorTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSetPreservesDisplayRefitWhenChangingOtherOptions() async throws {
|
||||||
|
try await withTemporaryTartHome {
|
||||||
|
let vmDir = try makeStandaloneVM(named: "display-refit", diskContents: "disk")
|
||||||
|
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
|
vmConfig.displayRefit = false
|
||||||
|
try vmConfig.save(toURL: vmDir.configURL)
|
||||||
|
|
||||||
|
let command = try Set.parseAsRoot(["display-refit", "--cpu", "4"]) as! Set
|
||||||
|
try await command.run()
|
||||||
|
|
||||||
|
let updatedConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
|
XCTAssertEqual(updatedConfig.displayRefit, false)
|
||||||
|
XCTAssertEqual(updatedConfig.cpuCount, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testRemoteAdditionalDiskRetainsTemporaryBackingFileLock() throws {
|
func testRemoteAdditionalDiskRetainsTemporaryBackingFileLock() throws {
|
||||||
try withTemporaryTartHome {
|
try withTemporaryTartHome {
|
||||||
let storage = try VMStorageOCI()
|
let storage = try VMStorageOCI()
|
||||||
|
|||||||
@@ -13,4 +13,12 @@ final class DockerConfigTests: XCTestCase {
|
|||||||
XCTAssertEqual(try config.findCredHelper(host: "456.dkr.ecr.us-east-1.amazonaws.com"), "ecr-login")
|
XCTAssertEqual(try config.findCredHelper(host: "456.dkr.ecr.us-east-1.amazonaws.com"), "ecr-login")
|
||||||
XCTAssertNil(try config.findCredHelper(host: "ghcr.io"))
|
XCTAssertNil(try config.findCredHelper(host: "ghcr.io"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAuthPasswordWithColons() throws {
|
||||||
|
let auth = Data("user:token:with:colons".utf8).base64EncodedString()
|
||||||
|
let credentials = try XCTUnwrap(DockerAuthConfig(auth: auth).decodeCredentials())
|
||||||
|
|
||||||
|
XCTAssertEqual(credentials.0, "user")
|
||||||
|
XCTAssertEqual(credentials.1, "token:with:colons")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ import XCTest
|
|||||||
@testable import tart
|
@testable import tart
|
||||||
|
|
||||||
final class TokenResponseTests: XCTestCase {
|
final class TokenResponseTests: XCTestCase {
|
||||||
|
func testFractionalIssuedAt() throws {
|
||||||
|
for timestamp in ["1970-01-01T00:00:00.125Z", "1970-01-01T01:30:00.125+01:30", "1970-01-01T00:00:00.125000000Z"] {
|
||||||
|
let data = Data("{\"token\":\"test-token\",\"expires_in\":3600,\"issued_at\":\"\(timestamp)\"}".utf8)
|
||||||
|
let response = try TokenResponse.parse(fromData: data)
|
||||||
|
|
||||||
|
XCTAssertEqual(response.issuedAt!.timeIntervalSince1970, 0.125, accuracy: 0.0001)
|
||||||
|
XCTAssertEqual(response.tokenExpiresAt.timeIntervalSince1970, 3600.125, accuracy: 0.0001)
|
||||||
|
XCTAssertFalse(response.isValid())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testBasic() throws {
|
func testBasic() throws {
|
||||||
var expectedTokenExpiresAtRange = DateInterval()
|
var expectedTokenExpiresAtRange = DateInterval()
|
||||||
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
|
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
|
||||||
|
|||||||
Reference in New Issue
Block a user