mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-02 04:01:12 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e75a59d54 | ||
|
|
1d3aa5ac81 | ||
|
|
261c1806df | ||
|
|
92a4b3164d | ||
|
|
3fdf82079a | ||
|
|
a05684157e | ||
|
|
e62e921eec | ||
|
|
e1bb565c3b | ||
|
|
9b26d30c42 | ||
|
|
ab32cb7e60 | ||
|
|
ea6fd814f5 | ||
|
|
e72fcd9b19 | ||
|
|
33a51b7344 | ||
|
|
5da1a085d3 | ||
|
|
f62949b6f4 | ||
|
|
d7561cab0b | ||
|
|
066f585c53 | ||
|
|
d8ac36b3bd | ||
|
|
4a454a3115 | ||
|
|
94e9425db7 |
+1
-1
@@ -5,7 +5,7 @@ set -e
|
||||
export VERSION="${CIRRUS_TAG:-0}"
|
||||
|
||||
mkdir -p .ci/pkg/
|
||||
cp .build/arm64-apple-macosx/debug/tart .ci/pkg/tart
|
||||
cp .build/arm64-apple-macosx/release/tart .ci/pkg/tart
|
||||
cp Resources/embedded.provisionprofile .ci/pkg/embedded.provisionprofile
|
||||
pkgbuild --root .ci/pkg/ --identifier com.github.cirruslabs.tart --version $VERSION \
|
||||
--scripts .ci/pkg/scripts \
|
||||
|
||||
+1
-3
@@ -80,8 +80,6 @@ task:
|
||||
always:
|
||||
dist_artifacts:
|
||||
path: "dist/*"
|
||||
pkg_artifacts:
|
||||
path: ".ci/*.pkg"
|
||||
|
||||
task:
|
||||
name: Release
|
||||
@@ -118,7 +116,7 @@ task:
|
||||
- swift -version
|
||||
release_script: goreleaser
|
||||
upload_sentry_debug_files_script:
|
||||
- cd .build/arm64-apple-macosx/debug/
|
||||
- cd .build/arm64-apple-macosx/release/
|
||||
# Generate and upload symbols
|
||||
- dsymutil tart
|
||||
- sentry-cli debug-files upload tart.dSYM/
|
||||
|
||||
+3
-8
@@ -3,7 +3,7 @@ project_name: tart
|
||||
before:
|
||||
hooks:
|
||||
- .ci/set-version.sh
|
||||
- swift build -c debug --product tart
|
||||
- swift build -c release --product tart
|
||||
|
||||
builds:
|
||||
- builder: prebuilt
|
||||
@@ -12,7 +12,7 @@ builds:
|
||||
goarch:
|
||||
- arm64
|
||||
prebuilt:
|
||||
path: .build/arm64-apple-macosx/debug/tart
|
||||
path: .build/arm64-apple-macosx/release/tart
|
||||
hooks:
|
||||
post:
|
||||
- gon gon.hcl
|
||||
@@ -23,17 +23,12 @@ archives:
|
||||
- src: Resources/embedded.provisionprofile
|
||||
dst: tart.app/Contents
|
||||
strip_parent: true
|
||||
- src: ".build/arm64-apple-macosx/debug/tart"
|
||||
- src: ".build/arm64-apple-macosx/release/tart"
|
||||
dst: tart.app/Contents/MacOS
|
||||
strip_parent: true
|
||||
hooks:
|
||||
after:
|
||||
- ./.ci/create-pkg.sh
|
||||
|
||||
release:
|
||||
prerelease: auto
|
||||
extra_files:
|
||||
- glob: ./.ci/Tart-{{ .Tag }}.pkg
|
||||
|
||||
brews:
|
||||
- name: tart
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="sign debug" type="ShConfigurationType">
|
||||
<option name="SCRIPT_TEXT" value="codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart" />
|
||||
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
|
||||
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/scripts/sign.sh" />
|
||||
<option name="SCRIPT_OPTIONS" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
||||
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
|
||||
<option name="INTERPRETER_PATH" value="/bin/zsh" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="EXECUTE_IN_TERMINAL" value="true" />
|
||||
<option name="EXECUTE_SCRIPT_FILE" value="false" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -1,8 +0,0 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="tart create" type="SwiftPackageManagerRunConfiguration" factoryName="Swift Package Run" PROGRAM_PARAMS="create latest --from-ipsw=latest" REDIRECT_INPUT="false" ELEVATE="false" USE_EXTERNAL_CONSOLE="false" PASS_PARENT_ENVS_2="true" PROJECT_NAME="tart" TARGET_NAME="tart" CONFIG_NAME="tart" RUN_TARGET_PROJECT_NAME="tart" RUN_TARGET_NAME="tart" WAS_MODIFIED="">
|
||||
<method v="2">
|
||||
<option name="SPM.BUILD_TASK_PROVIDER" enabled="true" />
|
||||
<option name="RunConfigurationTask" enabled="true" run_configuration_name="sign debug" run_configuration_type="ShConfigurationType" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -1,8 +0,0 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="tart run" type="SwiftPackageManagerRunConfiguration" factoryName="Swift Package Run" PROGRAM_PARAMS="run latest" REDIRECT_INPUT="false" ELEVATE="false" USE_EXTERNAL_CONSOLE="false" PASS_PARENT_ENVS_2="true" PROJECT_NAME="tart" TARGET_NAME="tart" CONFIG_NAME="tart" RUN_TARGET_PROJECT_NAME="tart" RUN_TARGET_NAME="tart" WAS_MODIFIED="">
|
||||
<method v="2">
|
||||
<option name="SPM.BUILD_TASK_PROVIDER" enabled="true" />
|
||||
<option name="RunConfigurationTask" enabled="true" run_configuration_name="sign debug" run_configuration_type="ShConfigurationType" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
+2
-2
@@ -23,8 +23,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||
"state" : {
|
||||
"revision" : "c3c19e29f775ee95b77aa3168f3e2fd6c20deba6",
|
||||
"version" : "7.31.3"
|
||||
"revision" : "ac224c437a3070ffe34460137ac8761eddaf2852",
|
||||
"version" : "8.3.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ let package = Package(
|
||||
.package(url: "https://github.com/antlr/antlr4", branch: "dev"),
|
||||
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
|
||||
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"),
|
||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "7.31.3"),
|
||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.3.3"),
|
||||
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
|
||||
],
|
||||
targets: [
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct Export: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Export VM to a file")
|
||||
static var configuration = CommandConfiguration(abstract: "Export VM to a compressed .tvm file")
|
||||
|
||||
@Argument(help: "Source VM name.")
|
||||
var name: String
|
||||
|
||||
@Argument(help: "Path to the destination file.")
|
||||
var path: String
|
||||
var path: String?
|
||||
|
||||
func run() async throws {
|
||||
let correctedPath: String
|
||||
|
||||
if let path = path {
|
||||
correctedPath = path
|
||||
} else {
|
||||
correctedPath = "\(name).tvm"
|
||||
|
||||
if FileManager.default.fileExists(atPath: correctedPath) {
|
||||
while true {
|
||||
if userWantsOverwrite(correctedPath) {
|
||||
break
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("exporting...")
|
||||
try VMStorageHelper.open(name).exportToArchive(path: path)
|
||||
|
||||
try VMStorageHelper.open(name).exportToArchive(path: correctedPath)
|
||||
}
|
||||
|
||||
func userWantsOverwrite(_ filename: String) -> Bool {
|
||||
print("file \(filename) already exists, are you sure you want to overwrite it? (yes, [no])? ", terminator: "")
|
||||
|
||||
let answer = readLine()!
|
||||
|
||||
return answer == "yes"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ struct Get: AsyncParsableCommand {
|
||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
let diskSizeInGb = try vmDir.sizeGB()
|
||||
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
||||
let running = try PIDLock(lockURL: vmDir.configURL).pid() > 0
|
||||
|
||||
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb, Display: vmConfig.display.description, Running: running)
|
||||
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb,
|
||||
Display: vmConfig.display.description, Running: try vmDir.running())
|
||||
print(format.renderSingle(info))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ import Network
|
||||
import SystemConfiguration
|
||||
import Sentry
|
||||
|
||||
enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
|
||||
case dhcp, arp
|
||||
|
||||
private(set) static var allValueStrings: [String] = Format.allCases.map { "\($0)"}
|
||||
}
|
||||
|
||||
struct IP: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
|
||||
|
||||
@@ -13,46 +19,47 @@ struct IP: AsyncParsableCommand {
|
||||
@Option(help: "Number of seconds to wait for a potential VM booting")
|
||||
var wait: UInt16 = 0
|
||||
|
||||
@Option(help: ArgumentHelp("Strategy for resolving IP address: dhcp or arp",
|
||||
discussion: """
|
||||
By default, Tart is looking up and parsing DHCP lease file to determine the IP of the VM.\n
|
||||
This method is fast and the most reliable but only returns local IP adresses.\n
|
||||
Alternatively, Tart can call external `arp` executable and parse it's output.\n
|
||||
In case of enabled Bridged Networking this method will return VM's IP address on the network interface used for Bridged Networking.\n
|
||||
Note that `arp` strategy won't work for VMs using `--net-softnet`.
|
||||
"""))
|
||||
var resolver: IPResolutionStrategy = .dhcp
|
||||
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||
|
||||
guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else {
|
||||
if try !vmDir.running() {
|
||||
throw RuntimeError.NoIPAddressFound("no IP address found, is your VM running?")
|
||||
}
|
||||
|
||||
let arpCache = try ARPCache()
|
||||
|
||||
if let ipViaARP = try arpCache.ResolveMACAddress(macAddress: vmMACAddress), ipViaARP != ipViaDHCP {
|
||||
// Capture the warning into Sentry
|
||||
SentrySDK.capture(message: "DHCP lease and ARP cache entries for a single MAC address differ") { scope in
|
||||
scope.setLevel(.warning)
|
||||
|
||||
scope.setContext(value: [
|
||||
"MAC address": vmMACAddress,
|
||||
"IP via ARP": ipViaARP,
|
||||
"IP via DHCP": ipViaDHCP,
|
||||
], key: "Address conflict details")
|
||||
|
||||
scope.add(Attachment(path: "/var/db/dhcpd_leases", filename: "dhcpd_leases.txt", contentType: "text/plain"))
|
||||
scope.add(Attachment(data: arpCache.arpCommandOutput, filename: "arp-an-output.txt", contentType: "text/plain"))
|
||||
guard let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: resolver, secondsToWait: wait) else {
|
||||
if (vmConfig.os == .linux && resolver == .arp) {
|
||||
throw RuntimeError.NoIPAddressFound("no IP address found, not all Linux distributions are compatible with ARP resolver")
|
||||
}
|
||||
|
||||
fputs("WARNING: DHCP lease and ARP cache entries for MAC address \(vmMACAddress) differ: "
|
||||
+ "got \(ipViaDHCP) and \(ipViaARP) respectively, consider reporting this case to"
|
||||
+ " https://github.com/cirruslabs/tart/issues/172\n", stderr)
|
||||
throw RuntimeError.NoIPAddressFound("no IP address found")
|
||||
}
|
||||
|
||||
print(ipViaDHCP)
|
||||
print(ip)
|
||||
}
|
||||
|
||||
static public func resolveIP(_ vmMACAddress: MACAddress, secondsToWait: UInt16) async throws -> IPv4Address? {
|
||||
static public func resolveIP(_ vmMACAddress: MACAddress, resolutionStrategy: IPResolutionStrategy = .dhcp, secondsToWait: UInt16 = 0) async throws -> IPv4Address? {
|
||||
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
|
||||
|
||||
repeat {
|
||||
if let leases = try Leases(), let ip = try leases.resolveMACAddress(macAddress: vmMACAddress) {
|
||||
return ip
|
||||
switch resolutionStrategy {
|
||||
case .arp:
|
||||
if let ip = try ARPCache().ResolveMACAddress(macAddress: vmMACAddress) {
|
||||
return ip
|
||||
}
|
||||
case .dhcp:
|
||||
if let leases = try Leases(), let ip = try leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
// wait a second
|
||||
|
||||
@@ -2,7 +2,7 @@ import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct Import: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Import VM from a file")
|
||||
static var configuration = CommandConfiguration(abstract: "Import VM from a compressed .tvm file")
|
||||
|
||||
@Argument(help: "Path to a file created with \"tart export\".")
|
||||
var path: String
|
||||
|
||||
@@ -6,6 +6,7 @@ fileprivate struct VMInfo: Encodable {
|
||||
let Source: String
|
||||
let Name: String
|
||||
let Size: Int
|
||||
let Running: Bool
|
||||
}
|
||||
|
||||
struct List: AsyncParsableCommand {
|
||||
@@ -32,17 +33,19 @@ struct List: AsyncParsableCommand {
|
||||
|
||||
func run() async throws {
|
||||
var infos: [VMInfo] = []
|
||||
|
||||
if source == nil || source == "local" {
|
||||
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
|
||||
try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB())
|
||||
try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running())
|
||||
})
|
||||
}
|
||||
|
||||
if source == nil || source == "oci" {
|
||||
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
|
||||
try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB())
|
||||
try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running())
|
||||
})
|
||||
}
|
||||
|
||||
if (quiet) {
|
||||
for info in infos {
|
||||
print(info.Name)
|
||||
|
||||
@@ -6,7 +6,7 @@ import Compression
|
||||
struct Push: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Push a VM to a registry")
|
||||
|
||||
@Argument(help: "local VM name")
|
||||
@Argument(help: "local or remote VM name")
|
||||
var localName: String
|
||||
|
||||
@Argument(help: "remote VM name(s)")
|
||||
@@ -28,7 +28,8 @@ struct Push: AsyncParsableCommand {
|
||||
var populateCache: Bool = false
|
||||
|
||||
func run() async throws {
|
||||
let localVMDir = try VMStorageLocal().open(localName)
|
||||
let ociStorage = VMStorageOCI()
|
||||
let localVMDir = try VMStorageHelper.open(localName)
|
||||
|
||||
// Parse remote names supplied by the user
|
||||
let remoteNames = try remoteNames.map{
|
||||
@@ -53,23 +54,55 @@ struct Push: AsyncParsableCommand {
|
||||
defaultLogger.appendNewLine("pushing \(localName) to "
|
||||
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
|
||||
|
||||
let pushedRemoteName = try await localVMDir.pushToRegistry(
|
||||
registry: registry,
|
||||
references: remoteNamesForRegistry.map{ $0.reference.value },
|
||||
chunkSizeMb: chunkSize
|
||||
)
|
||||
let references = remoteNamesForRegistry.map{ $0.reference.value }
|
||||
|
||||
// Populate the local cache (if requested)
|
||||
let pushedRemoteName: RemoteName
|
||||
// If we're pushing a local OCI VM, check if points to an already existing registry manifest
|
||||
// and if so, only upload manifests (without config, disk and NVRAM) to the user-specified references
|
||||
if let remoteName = try? RemoteName(localName) {
|
||||
pushedRemoteName = try await lightweightPushToRegistry(
|
||||
registry: registry,
|
||||
remoteName: remoteName,
|
||||
references: references
|
||||
)
|
||||
} else {
|
||||
pushedRemoteName = try await localVMDir.pushToRegistry(
|
||||
registry: registry,
|
||||
references: references,
|
||||
chunkSizeMb: chunkSize
|
||||
)
|
||||
// Populate the local cache (if requested)
|
||||
if populateCache {
|
||||
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
|
||||
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
|
||||
}
|
||||
}
|
||||
|
||||
// link the rest remote names
|
||||
if populateCache {
|
||||
let ociStorage = VMStorageOCI()
|
||||
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
|
||||
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
|
||||
for remoteName in remoteNamesForRegistry {
|
||||
try ociStorage.link(from: remoteName, to: pushedRemoteName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lightweightPushToRegistry(registry: Registry, remoteName: RemoteName, references: [String]) async throws -> RemoteName {
|
||||
// Is the local OCI VM already present in the registry?
|
||||
let digest = try VMStorageOCI().digest(remoteName)
|
||||
|
||||
let (remoteManifest, _) = try await registry.pullManifest(reference: digest)
|
||||
|
||||
// Overwrite registry's references with the retrieved manifest
|
||||
for reference in references {
|
||||
defaultLogger.appendNewLine("pushing manifest for \(reference)...")
|
||||
|
||||
_ = try await registry.pushManifest(reference: reference, manifest: remoteManifest)
|
||||
}
|
||||
|
||||
return RemoteName(host: registry.baseURL.host!, namespace: registry.namespace,
|
||||
reference: Reference(digest: digest))
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection where Element == RemoteName {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ArgumentParser
|
||||
import Cocoa
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
import Virtualization
|
||||
@@ -17,13 +18,24 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Don't open a UI window.",
|
||||
discussion: "Useful for integrating Tart VMs into other tools.\nUse `tart ip` in order to get an IP for SSHing or VNCing into the VM."))
|
||||
discussion: "Useful for integrating Tart VMs into other tools.\nUse `tart ip` in order to get an IP for SSHing or VNCing into the VM."))
|
||||
var noGraphics: Bool = false
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Open serial console in /dev/ttySXX",
|
||||
discussion: "Useful for debugging Linux Kernel."))
|
||||
var serial: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp(
|
||||
"Attach an externally created serial console",
|
||||
discussion: "Alternative to `--serial` flag for programmatic integrations."
|
||||
))
|
||||
var serialPath: String?
|
||||
|
||||
@Flag(help: "Force open a UI window, even when VNC is enabled.")
|
||||
var graphics: Bool = false
|
||||
|
||||
@Flag(help: "Boot into recovery mode")
|
||||
@Flag(help: "Boot into recovery mode")
|
||||
var recovery: Bool = false
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
@@ -118,11 +130,30 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
var serialPorts: [VZSerialPortConfiguration] = []
|
||||
if serial {
|
||||
let tty_fd = createPTY()
|
||||
if (tty_fd < 0) {
|
||||
throw RuntimeError.VMConfigurationError("Failed to create PTY")
|
||||
}
|
||||
let tty_read = FileHandle.init(fileDescriptor: tty_fd)
|
||||
let tty_write = FileHandle.init(fileDescriptor: tty_fd)
|
||||
serialPorts.append(createSerialPortConfiguration(tty_read, tty_write))
|
||||
} else if serialPath != nil {
|
||||
let tty_read = FileHandle.init(forReadingAtPath: serialPath!)
|
||||
let tty_write = FileHandle.init(forWritingAtPath: serialPath!)
|
||||
if (tty_read == nil || tty_write == nil) {
|
||||
throw RuntimeError.VMConfigurationError("Failed to open PTY")
|
||||
}
|
||||
serialPorts.append(createSerialPortConfiguration(tty_read!, tty_write!))
|
||||
}
|
||||
|
||||
vm = try VM(
|
||||
vmDir: vmDir,
|
||||
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
|
||||
additionalDiskAttachments: additionalDiskAttachments,
|
||||
directorySharingDevices: directoryShares() + rosettaDirectoryShare()
|
||||
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
|
||||
serialPorts: serialPorts
|
||||
)
|
||||
|
||||
let vncImpl: VNC? = try {
|
||||
@@ -198,6 +229,16 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private func createSerialPortConfiguration(_ tty_read: FileHandle, _ tty_write: FileHandle) -> VZVirtioConsoleDeviceSerialPortConfiguration {
|
||||
let serialPortConfiguration = VZVirtioConsoleDeviceSerialPortConfiguration()
|
||||
let serialPortAttachment = VZFileHandleSerialPortAttachment(
|
||||
fileHandleForReading: tty_read,
|
||||
fileHandleForWriting: tty_write)
|
||||
|
||||
serialPortConfiguration.attachment = serialPortAttachment
|
||||
return serialPortConfiguration
|
||||
}
|
||||
|
||||
func isInteractiveSession() -> Bool {
|
||||
isatty(STDOUT_FILENO) == 1
|
||||
}
|
||||
@@ -351,13 +392,21 @@ struct Run: AsyncParsableCommand {
|
||||
nsApp.applicationIconImage = NSImage(data: AppIconData)
|
||||
|
||||
struct MainApp: App {
|
||||
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup(vm!.name) {
|
||||
Group {
|
||||
VMView(vm: vm!).onAppear {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
}.onDisappear {
|
||||
NSApplication.shared.terminate(self)
|
||||
let ret = kill(getpid(), SIGINT)
|
||||
if ret != 0 {
|
||||
// Fallback to the old termination method that doesn't
|
||||
// propagate the cancellation to Task's in case graceful
|
||||
// termination via kill(2) is not successful
|
||||
NSApplication.shared.terminate(self)
|
||||
}
|
||||
}
|
||||
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
|
||||
}.commands {
|
||||
@@ -369,7 +418,18 @@ struct Run: AsyncParsableCommand {
|
||||
CommandGroup(replacing: .undoRedo, addition: {})
|
||||
CommandGroup(replacing: .windowSize, addition: {})
|
||||
// Replace some standard menu options
|
||||
CommandGroup(replacing: .appInfo) { AboutTart() }
|
||||
CommandGroup(replacing: .appInfo) { AboutTart(config: vm!.config) }
|
||||
CommandMenu("Control") {
|
||||
Button("Start") {
|
||||
Task { try await vm!.virtualMachine.start() }
|
||||
}
|
||||
Button("Stop") {
|
||||
Task { try await vm!.virtualMachine.stop() }
|
||||
}
|
||||
Button("Request Stop") {
|
||||
Task { try await vm!.virtualMachine.requestStop() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -378,14 +438,42 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
// The only way to fully remove Edit menu item.
|
||||
class MinimalMenuAppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
||||
let indexOfEditMenu = 2
|
||||
|
||||
func applicationDidFinishLaunching(_ : Notification) {
|
||||
NSApplication.shared.mainMenu?.removeItem(at: indexOfEditMenu)
|
||||
}
|
||||
}
|
||||
|
||||
struct AboutTart: View {
|
||||
var credits: NSAttributedString
|
||||
|
||||
init(config: VMConfig) {
|
||||
let mutableAttrStr = NSMutableAttributedString()
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.alignment = NSTextAlignment.center
|
||||
let attrCenter: [NSAttributedString.Key : Any] = [
|
||||
.paragraphStyle: style,
|
||||
]
|
||||
mutableAttrStr.append(NSAttributedString(string: "CPU: \(config.cpuCount) cores\n", attributes: attrCenter))
|
||||
mutableAttrStr.append(NSAttributedString(string: "Memory: \(config.memorySize / 1024 / 1024) MB\n", attributes: attrCenter))
|
||||
mutableAttrStr.append(NSAttributedString(string: "Display: \(config.display.description)\n", attributes: attrCenter))
|
||||
mutableAttrStr.append(NSAttributedString(string: "https://github.com/cirruslabs/tart", attributes: [
|
||||
.paragraphStyle: style,
|
||||
.link : "https://github.com/cirruslabs/tart"
|
||||
]))
|
||||
credits = mutableAttrStr
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button("About Tart") {
|
||||
NSApplication.shared.orderFrontStandardAboutPanel(options: [
|
||||
NSApplication.AboutPanelOptionKey.applicationIcon: NSApplication.shared.applicationIconImage as Any,
|
||||
NSApplication.AboutPanelOptionKey.applicationName: "Tart",
|
||||
NSApplication.AboutPanelOptionKey.applicationVersion: CI.version,
|
||||
NSApplication.AboutPanelOptionKey.credits: try! NSAttributedString(markdown: "https://github.com/cirruslabs/tart"),
|
||||
NSApplication.AboutPanelOptionKey.credits: credits,
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -398,7 +486,8 @@ struct VMView: NSViewRepresentable {
|
||||
|
||||
func makeNSView(context: Context) -> NSViewType {
|
||||
let machineView = VZVirtualMachineView()
|
||||
machineView.capturesSystemKeys = true
|
||||
// so keys like take a windows screenshot (cmd+shift+4+space) works on the host and not guest
|
||||
machineView.capturesSystemKeys = false
|
||||
return machineView
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ struct ARPCache {
|
||||
self.arpCommandOutput = arpCommandOutput
|
||||
}
|
||||
|
||||
func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
|
||||
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
||||
let lines = String(decoding: arpCommandOutput, as: UTF8.self)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: "\n")
|
||||
@@ -95,11 +95,6 @@ struct ARPCache {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ class Leases {
|
||||
return rawLeases
|
||||
}
|
||||
|
||||
func resolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
||||
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
||||
leases[macAddress]?.ip
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
func createPTY() -> Int32 {
|
||||
var tty_fd: Int32 = -1
|
||||
var sfd: Int32 = -1
|
||||
var termios_ = termios()
|
||||
let tty_path = UnsafeMutablePointer<CChar>.allocate(capacity: 1024)
|
||||
|
||||
var res = openpty(&tty_fd, &sfd, tty_path, nil, nil);
|
||||
if (res < 0) {
|
||||
perror("openpty error")
|
||||
return -1
|
||||
}
|
||||
|
||||
// close slave file descriptor
|
||||
close(sfd)
|
||||
|
||||
res = fcntl(tty_fd, F_GETFL)
|
||||
if (res < 0) {
|
||||
perror("fcntl F_GETFL error")
|
||||
return res
|
||||
}
|
||||
|
||||
// set serial nonblocking
|
||||
res = fcntl(tty_fd, F_SETFL, res | O_NONBLOCK)
|
||||
if (res < 0) {
|
||||
perror("fcntl F_SETFL O_NONBLOCK error")
|
||||
return res
|
||||
}
|
||||
|
||||
// set baudrate to 115200
|
||||
tcgetattr(tty_fd, &termios_)
|
||||
cfsetispeed(&termios_, speed_t(B115200))
|
||||
cfsetospeed(&termios_, speed_t(B115200))
|
||||
if (tcsetattr(tty_fd, TCSANOW, &termios_) != 0) {
|
||||
perror("tcsetattr error")
|
||||
return -1
|
||||
}
|
||||
|
||||
print("Successfully open pty \(String(cString: tty_path))")
|
||||
|
||||
tty_path.deallocate()
|
||||
return tty_fd
|
||||
}
|
||||
+13
-5
@@ -40,7 +40,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
init(vmDir: VMDirectory,
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = []
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = []
|
||||
) throws {
|
||||
name = vmDir.name
|
||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
@@ -54,7 +55,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
||||
nvramURL: vmDir.nvramURL, vmConfig: config,
|
||||
network: network, additionalDiskAttachments: additionalDiskAttachments,
|
||||
directorySharingDevices: directorySharingDevices
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
@@ -132,7 +134,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
diskSizeGB: UInt16,
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = []
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = []
|
||||
) async throws {
|
||||
var ipswURL = ipswURL
|
||||
|
||||
@@ -179,7 +182,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||
vmConfig: config, network: network,
|
||||
additionalDiskAttachments: additionalDiskAttachments,
|
||||
directorySharingDevices: directorySharingDevices
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
@@ -261,7 +265,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
vmConfig: VMConfig,
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration]
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
|
||||
serialPorts: [VZSerialPortConfiguration]
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
@@ -308,6 +313,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// Directory sharing devices
|
||||
configuration.directorySharingDevices = directorySharingDevices
|
||||
|
||||
// Serial Port
|
||||
configuration.serialPorts = serialPorts
|
||||
|
||||
try configuration.validate()
|
||||
|
||||
return configuration
|
||||
|
||||
@@ -81,7 +81,8 @@ extension VMDirectory {
|
||||
try? decodeStream.close()
|
||||
}
|
||||
|
||||
guard let extractStream = ArchiveStream.extractStream(extractingTo: FilePath(baseURL.path)) else {
|
||||
guard let extractStream = ArchiveStream.extractStream(extractingTo: FilePath(baseURL.path),
|
||||
flags: [.ignoreOperationNotPermitted]) else {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
throw RuntimeError.ImportFailed("ArchiveStream.extractStream() failed: \(details)")
|
||||
|
||||
@@ -26,6 +26,10 @@ struct VMDirectory: Prunable {
|
||||
baseURL
|
||||
}
|
||||
|
||||
func running() throws -> Bool {
|
||||
try PIDLock(lockURL: configURL).pid() != 0
|
||||
}
|
||||
|
||||
static func temporary() throws -> VMDirectory {
|
||||
let tmpDir = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: false)
|
||||
@@ -51,9 +55,9 @@ struct VMDirectory: Prunable {
|
||||
try? FileManager.default.removeItem(at: nvramURL)
|
||||
}
|
||||
|
||||
func validate() throws {
|
||||
func validate(userFriendlyName: String) throws {
|
||||
if !FileManager.default.fileExists(atPath: baseURL.path) {
|
||||
throw RuntimeError.VMDoesNotExist(name: baseURL.lastPathComponent)
|
||||
throw RuntimeError.VMDoesNotExist(name: userFriendlyName)
|
||||
}
|
||||
|
||||
if !initialized {
|
||||
|
||||
@@ -41,6 +41,7 @@ extension Error {
|
||||
}
|
||||
|
||||
enum RuntimeError : Error {
|
||||
case VMConfigurationError(_ message: String)
|
||||
case VMDoesNotExist(name: String)
|
||||
case VMMissingFiles(_ message: String)
|
||||
case VMNotRunning(_ message: String)
|
||||
@@ -56,6 +57,7 @@ enum RuntimeError : Error {
|
||||
case ExportFailed(_ message: String)
|
||||
case ImportFailed(_ message: String)
|
||||
case SoftnetFailed(_ message: String)
|
||||
case OCIStorageError(_ message: String)
|
||||
}
|
||||
|
||||
protocol HasExitCode {
|
||||
@@ -65,6 +67,8 @@ protocol HasExitCode {
|
||||
extension RuntimeError : CustomStringConvertible {
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .VMConfigurationError(let message):
|
||||
return message
|
||||
case .VMDoesNotExist(let name):
|
||||
return "the specified VM \"\(name)\" does not exist"
|
||||
case .VMMissingFiles(let message):
|
||||
@@ -95,6 +99,8 @@ extension RuntimeError : CustomStringConvertible {
|
||||
return "VM import failed: \(message)"
|
||||
case .SoftnetFailed(let message):
|
||||
return "Softnet failed: \(message)"
|
||||
case .OCIStorageError(let message):
|
||||
return "OCI storage error: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class VMStorageLocal {
|
||||
func open(_ name: String) throws -> VMDirectory {
|
||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||
|
||||
try vmDir.validate()
|
||||
try vmDir.validate(userFriendlyName: name)
|
||||
|
||||
return vmDir
|
||||
}
|
||||
|
||||
@@ -16,10 +16,20 @@ class VMStorageOCI: PrunableStorage {
|
||||
VMDirectory(baseURL: vmURL(name)).initialized
|
||||
}
|
||||
|
||||
func digest(_ name: RemoteName) throws -> String {
|
||||
let digest = vmURL(name).resolvingSymlinksInPath().lastPathComponent
|
||||
|
||||
if !digest.starts(with: "sha256:") {
|
||||
throw RuntimeError.OCIStorageError("\(name) is not a digest and doesn't point to a digest")
|
||||
}
|
||||
|
||||
return digest
|
||||
}
|
||||
|
||||
func open(_ name: RemoteName) throws -> VMDirectory {
|
||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||
|
||||
try vmDir.validate()
|
||||
try vmDir.validate(userFriendlyName: name.description)
|
||||
|
||||
try vmDir.baseURL.updateAccessDate()
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ final class MACAddressResolverTests: XCTestCase {
|
||||
""")
|
||||
|
||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
}
|
||||
|
||||
func testMultipleEntries() throws {
|
||||
@@ -28,8 +28,8 @@ final class MACAddressResolverTests: XCTestCase {
|
||||
""")
|
||||
|
||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
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")!))
|
||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,17 @@ Tart already powers several CI services mentioned above including our own [Cirru
|
||||
For services that haven't leveraged Tart yet, we offer fully managed runners via a monthly subscription.
|
||||
*Cirrus Runners* is the fastest way to get your current CI workflows to benefit from Apple Silicon hardware. No need to manage infrastructure or migrate to another CI provider.
|
||||
|
||||
## Testimonials from customers
|
||||
|
||||
Sebastian Jachec, Mobile Engineer at [Daybridge](https://www.daybridge.com/).
|
||||
|
||||
> It’s been plain-sailing with the Cirrus Runners — they’ve been great! They’re consistently 60+% faster on workflows that we previously used Github Actions’ macOS runners for.
|
||||
|
||||
Max Lapides, Senior Mobile Engineer at [Tonal](https://www.tonal.com/).
|
||||
|
||||
> Previously, we were using the GitHub‑hosted macOS runners and our iOS build took ~30 minutes. Now with Cirrus Runners, the iOS build only takes ~12 minutes. That’s a huge boost to our productivity, and for only $150/month per runner it is much less expensive too.
|
||||
|
||||
|
||||
## Configuring Cirrus Runners
|
||||
|
||||
Configuring Cirrus Runners for GitHub Actions is as simple as installing [Cirrus Runners App](https://github.com/apps/cirrus-runners).
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# GitLab Runner Executor
|
||||
|
||||
It is possible to run GitLab jobs in isolated ephemeral Tart Virtual Machines via [Tart Executor](https://github.com/cirruslabs/gitlab-tart-executor).
|
||||
Tart Executor utilizes [custom executor](https://docs.gitlab.com/runner/executors/custom.html) feature of GitLab Runner.
|
||||
|
||||
# Basic Configuration
|
||||
|
||||
Configuring Tart Executor for GitLab Runner is as simple as installing `gitlab-tart-executor` binary from Homebrew:
|
||||
|
||||
```bash
|
||||
brew install cirruslabs/cli/gitlab-tart-executor
|
||||
```
|
||||
|
||||
And updating configuration of your self-hosted GitLab Runner to use `gitlab-tart-executor` binary:
|
||||
|
||||
```toml
|
||||
concurrent = 2
|
||||
|
||||
[[runners]]
|
||||
# ...
|
||||
executor = "custom"
|
||||
builds_dir = "/Users/admin/builds" # directory inside the
|
||||
cache_dir = "/Users/admin/cache"
|
||||
[runners.feature_flags]
|
||||
FF_RESOLVE_FULL_TLS_CHAIN = false
|
||||
[runners.custom]
|
||||
prepare_exec = "gitlab-tart-executor"
|
||||
prepare_args = ["prepare"]
|
||||
run_exec = "gitlab-tart-executor"
|
||||
run_args = ["run"]
|
||||
cleanup_exec = "gitlab-tart-executor"
|
||||
cleanup_args = ["cleanup"]
|
||||
```
|
||||
|
||||
Now you can use Tart Images in your `.gitlab-ci.yml`:
|
||||
|
||||
```yaml
|
||||
# You can use any remote Tart Image.
|
||||
# Tart Executor will pull it from the registry and use it for creating ephemeral VMs.
|
||||
image: ghcr.io/cirruslabs/macos-ventura-base:latest
|
||||
|
||||
test:
|
||||
tags:
|
||||
- tart-installed # in case you tagged runners with Tart Executor installed
|
||||
script:
|
||||
- uname -a
|
||||
```
|
||||
|
||||
For more advanced configuration please refer to [GitLab Tart Executor repository](https://github.com/cirruslabs/gitlab-tart-executor).
|
||||
@@ -1,4 +1,4 @@
|
||||
source = [".build/arm64-apple-macosx/debug/tart"]
|
||||
source = [".build/arm64-apple-macosx/release/tart"]
|
||||
bundle_id = "com.github.cirruslabs.tart"
|
||||
|
||||
apple_id {
|
||||
|
||||
@@ -87,6 +87,7 @@ nav:
|
||||
- "Quick Start": quick-start.md
|
||||
- "Integrations":
|
||||
- "GitHub Actions": integrations/github-actions.md
|
||||
- "GitLab Runner": integrations/gitlab-runner.md
|
||||
- "Self-hosted CI": integrations/cirrus-cli.md
|
||||
- "Managing VMs": integrations/vm-management.md
|
||||
- "Licensing": licensing.md
|
||||
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
|
||||
# helper script to build and run a signed tart binary
|
||||
# usage: ./scripts/run-signed.sh run ventura-base
|
||||
|
||||
set -e
|
||||
|
||||
swift build --product tart
|
||||
codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||
|
||||
.build/debug/tart "$@"
|
||||
Reference in New Issue
Block a user