mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-11 00:15:33 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9001304c8 | ||
|
|
4e20ea8f72 | ||
|
|
678ce0a55a | ||
|
|
8273ae66e1 | ||
|
|
4a9316a377 | ||
|
|
6321d547cf | ||
|
|
f241e21614 | ||
|
|
0eca923604 | ||
|
|
87f29cc11f | ||
|
|
90d1393137 | ||
|
|
625d431d10 | ||
|
|
4648e1aea1 | ||
|
|
4b62b73015 | ||
|
|
ae7018c31f | ||
|
|
e54c89da0c | ||
|
|
9a0ec3e6b0 | ||
|
|
400f85a493 | ||
|
|
0105280b5d | ||
|
|
c063c5bdc2 |
+5
-3
@@ -2,8 +2,10 @@
|
||||
|
||||
set -e
|
||||
|
||||
export VERSION="${CIRRUS_TAG:-0}"
|
||||
|
||||
mkdir -p .ci/pkg/
|
||||
cp .build/arm64-apple-macosx/debug/tart .ci/pkg/
|
||||
pkgbuild --root .ci/pkg --version "${CIRRUS_TAG:-0}" --install-location /usr/local/bin/ --identifier com.github.cirruslabs.tart --sign "Developer ID Installer: Fedor Korotkov (9M2P8L4D89)" ./dist/Tart.pkg
|
||||
xcrun notarytool submit ./dist/Tart.pkg --keychain-profile "notarytool" --wait
|
||||
xcrun stapler staple ./dist/Tart.pkg
|
||||
pkgbuild --root .ci/pkg --version $VERSION --install-location /usr/local/bin/ --identifier com.github.cirruslabs.tart --sign "Developer ID Installer: Cirrus Labs, Inc. (9M2P8L4D89)" "./dist/Tart-$VERSION.pkg"
|
||||
xcrun notarytool submit "./dist/Tart-$VERSION.pkg" --keychain-profile "notarytool" --wait
|
||||
xcrun stapler staple "./dist/Tart-$VERSION.pkg"
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ task:
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||
env:
|
||||
MACOS_CERTIFICATE: ENCRYPTED[8a6930a8c1286e7e536ea41b7647ea40e99174ad15e9cfcc753754fea55a619b355415629dff515b54a8921643e314e5]
|
||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
github: [cirruslabs]
|
||||
+1
-1
@@ -28,7 +28,7 @@ archives:
|
||||
release:
|
||||
prerelease: auto
|
||||
extra_files:
|
||||
- glob: ./dist/Tart.pkg
|
||||
- glob: ./dist/Tart-{{ .Tag }}.pkg
|
||||
|
||||
brews:
|
||||
- name: tart
|
||||
|
||||
@@ -8,6 +8,22 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
|
||||
* Use Tart Packer Plugin to automate VM creation.
|
||||
* Built-in CI integration.
|
||||
|
||||
*Tart* is already adopted by several automation services:
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://codemagic.io/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Codemagic.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://testingbot.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Usage
|
||||
|
||||
Try running a Tart VM on your Apple Silicon device running macOS Monterey or later (will download a 25 GB image):
|
||||
|
||||
```shell
|
||||
@@ -222,8 +238,8 @@ This invocation calls the `tart pull` implicitly (if the image is not being pres
|
||||
<details>
|
||||
<summary>Why Tart is free and open sourced?</summary>
|
||||
|
||||
Tart is a relatively small project, and it didn't feel right to try to monetize it.
|
||||
Apple did all the heavy lifting with their `Virtualization.Framework`.
|
||||
Apple did all the heavy lifting with their `Virtualization.Framework` and it just felt right to develop Tart in the open.
|
||||
Please consider [becoming a sponsor](https://github.com/sponsors/cirruslabs) if you find Tart saving a substantial amount of money on licensing and engineering hours for your company.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -252,3 +268,21 @@ This invocation calls the `tart pull` implicitly (if the image is not being pres
|
||||
Tart is limited by functionality of Apple's `Virtualization.Framework`. At the moment `Virtualization.Framework`
|
||||
doesn't support nested virtualization.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Changing the default NAT subnet</summary>
|
||||
|
||||
To change the default network to `192.168.77.1`:
|
||||
|
||||
```
|
||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Address -string 192.168.77.1
|
||||
```
|
||||
|
||||
Note that even through a network would normally be specified as `192.168.77.0`, the [vmnet framework](https://developer.apple.com/documentation/vmnet) seems to treat this as a starting address too and refuses to pick up such network-like values.
|
||||
|
||||
The default subnet mask `255.255.255.0` should suffice for most use-cases, however, you can also change it to `255.255.0.0`, for example:
|
||||
|
||||
```
|
||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Mask -string 255.255.0.0
|
||||
```
|
||||
</details>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dbcf6f65f26c0e17b2fe55a9712fc783315cd64905a0febce758b31d7e4e923b
|
||||
size 6787
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4239996f30145992936cfc8faa0232fa8904aedf4de61e30ef4c4fc86cad587f
|
||||
size 14588
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:227192fcd215c9cc05b1ebcd2616bd1f8c95be184726f64d91fc4507a88c66e1
|
||||
size 18393
|
||||
@@ -4,5 +4,7 @@
|
||||
<dict>
|
||||
<key>com.apple.security.virtualization</key>
|
||||
<true/>
|
||||
<key>com.apple.vm.networking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
|
||||
@@ -32,12 +32,22 @@ struct Clone: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
try tmpVMDirLock.lock()
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
||||
try lock.lock()
|
||||
|
||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||
try localStorage.move(newName, from: tmpVMDir)
|
||||
|
||||
try lock.unlock()
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
|
||||
@@ -27,6 +27,11 @@ struct Create: AsyncParsableCommand {
|
||||
func run() async throws {
|
||||
do {
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
try tmpVMDirLock.lock()
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
if let fromIPSW = fromIPSW {
|
||||
if fromIPSW == "latest" {
|
||||
|
||||
@@ -101,7 +101,7 @@ struct Prune: AsyncParsableCommand {
|
||||
break
|
||||
}
|
||||
|
||||
cacheReclaimedBytes -= try prunable.sizeBytes()
|
||||
cacheReclaimedBytes += try prunable.sizeBytes()
|
||||
try prunable.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct Rename: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Rename a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
|
||||
@Argument(help: "new VM name")
|
||||
var newName: String
|
||||
|
||||
func validate() throws {
|
||||
if newName.contains("/") {
|
||||
throw ValidationError("<new-name> should be a local name")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let localStorage = VMStorageLocal()
|
||||
|
||||
if !localStorage.exists(name) {
|
||||
throw ValidationError("failed to rename a non-existent VM: \(name)")
|
||||
}
|
||||
|
||||
if localStorage.exists(newName) {
|
||||
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
|
||||
}
|
||||
|
||||
try localStorage.rename(name, newName)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,15 +47,28 @@ struct Run: AsyncParsableCommand {
|
||||
@Option(help: ArgumentHelp("""
|
||||
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\")
|
||||
""", discussion: """
|
||||
Requires host to be macOS 13.0 (Ventura) or newer.
|
||||
All shared directories are automatically mounted to "/Volumes/My Shared Files" directory on macOS,
|
||||
while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point".
|
||||
For macOS guests, they must be running macOS 13.0 (Ventura) or newer.
|
||||
""", valueName: "name:path[:ro]"))
|
||||
var dir: [String] = []
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
Use bridged networking instead of the default shared (NAT) networking \n(e.g. --net-bridged=en0 or --net-bridged=\"Wi-Fi\")
|
||||
""", discussion: """
|
||||
Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces.
|
||||
""", valueName: "interface name"))
|
||||
var netBridged: String?
|
||||
|
||||
func validate() throws {
|
||||
if vnc && vncExperimental {
|
||||
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||
}
|
||||
|
||||
if withSoftnet && netBridged != nil {
|
||||
throw ValidationError("--with-softnet and --net-bridged are mutually exclusive")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -63,7 +76,7 @@ struct Run: AsyncParsableCommand {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
vm = try VM(
|
||||
vmDir: vmDir,
|
||||
withSoftnet: withSoftnet,
|
||||
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
|
||||
additionalDiskAttachments: additionalDiskAttachments(),
|
||||
directoryShares: directoryShares()
|
||||
)
|
||||
@@ -123,11 +136,53 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? {
|
||||
if withSoftnet {
|
||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
|
||||
return try Softnet(vmMACAddress: config.macAddress.string)
|
||||
}
|
||||
|
||||
if let netBridged = netBridged {
|
||||
let matchingInterfaces = VZBridgedNetworkInterface.networkInterfaces.filter { interface in
|
||||
interface.identifier == netBridged || interface.localizedDisplayName == netBridged
|
||||
}
|
||||
|
||||
if matchingInterfaces.isEmpty {
|
||||
let available = bridgeInterfaces().joined(separator: ", ")
|
||||
throw ValidationError("no bridge interfaces matched \"\(netBridged)\", "
|
||||
+ "available interfaces: \(available)")
|
||||
}
|
||||
|
||||
if matchingInterfaces.count > 1 {
|
||||
throw ValidationError("more than one bridge interface matched \"\(netBridged)\", "
|
||||
+ "consider refining the search criteria")
|
||||
}
|
||||
|
||||
return NetworkBridged(interface: matchingInterfaces.first!)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func bridgeInterfaces() -> [String] {
|
||||
VZBridgedNetworkInterface.networkInterfaces.map { interface in
|
||||
var bridgeDescription = interface.identifier
|
||||
|
||||
if let localizedDisplayName = interface.localizedDisplayName {
|
||||
bridgeDescription += " (or \"\(localizedDisplayName)\")"
|
||||
}
|
||||
|
||||
return bridgeDescription
|
||||
}
|
||||
}
|
||||
|
||||
func additionalDiskAttachments() throws -> [VZDiskImageStorageDeviceAttachment] {
|
||||
var result: [VZDiskImageStorageDeviceAttachment] = []
|
||||
let readOnlySuffix = ":ro"
|
||||
let expandedDiskPaths = disk.map { NSString(string:$0).expandingTildeInPath }
|
||||
|
||||
for rawDisk in disk {
|
||||
for rawDisk in expandedDiskPaths {
|
||||
if rawDisk.hasSuffix(readOnlySuffix) {
|
||||
result.append(try VZDiskImageStorageDeviceAttachment(
|
||||
url: URL(fileURLWithPath: String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count))),
|
||||
@@ -166,7 +221,7 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
let (name, path) = (String(splits[0]), String(splits[1]))
|
||||
|
||||
result.append(DirectoryShare(name: name, path: URL(fileURLWithPath: path), readOnly: readOnly))
|
||||
result.append(DirectoryShare(name: name, path: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath), readOnly: readOnly))
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -3,8 +3,9 @@ import Foundation
|
||||
struct Config {
|
||||
let tartHomeDir: URL
|
||||
let tartCacheDir: URL
|
||||
let tartTmpDir: URL
|
||||
|
||||
init() {
|
||||
init() throws {
|
||||
var tartHomeDir: URL
|
||||
|
||||
if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] {
|
||||
@@ -14,9 +15,27 @@ struct Config {
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".tart", isDirectory: true)
|
||||
}
|
||||
|
||||
self.tartHomeDir = tartHomeDir
|
||||
|
||||
tartCacheDir = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tartCacheDir, withIntermediateDirectories: true)
|
||||
|
||||
tartTmpDir = tartHomeDir.appendingPathComponent("tmp", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tartTmpDir, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func gc() throws {
|
||||
for entry in try FileManager.default.contentsOfDirectory(at: tartTmpDir,
|
||||
includingPropertiesForKeys: [], options: []) {
|
||||
let lock = try FileLock(lockURL: entry)
|
||||
if try !lock.trylock() {
|
||||
continue
|
||||
}
|
||||
|
||||
try FileManager.default.removeItem(at: entry)
|
||||
|
||||
try lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
static func jsonEncoder() -> JSONEncoder {
|
||||
|
||||
+26
-1
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
class HelperProgramCredentialsProvider: CredentialsProvider {
|
||||
class DockerConfigCredentialsProvider: CredentialsProvider {
|
||||
func retrieve(host: String) throws -> (String, String)? {
|
||||
let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json")
|
||||
if !FileManager.default.fileExists(atPath: dockerConfigURL.path) {
|
||||
@@ -8,6 +8,9 @@ class HelperProgramCredentialsProvider: CredentialsProvider {
|
||||
}
|
||||
let config = try JSONDecoder().decode(DockerConfig.self, from: Data(contentsOf: dockerConfigURL))
|
||||
|
||||
if let credentialsFromAuth = config.auths?[host]?.decodeCredentials() {
|
||||
return credentialsFromAuth
|
||||
}
|
||||
if let helperProgram = config.credHelpers?[host] {
|
||||
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
|
||||
}
|
||||
@@ -54,9 +57,31 @@ class HelperProgramCredentialsProvider: CredentialsProvider {
|
||||
}
|
||||
|
||||
struct DockerConfig: Codable {
|
||||
var auths: Dictionary<String, DockerAuthConfig>? = Dictionary()
|
||||
var credHelpers: Dictionary<String, String>? = Dictionary()
|
||||
}
|
||||
|
||||
struct DockerAuthConfig: Codable {
|
||||
var auth: String? = nil
|
||||
|
||||
func decodeCredentials() -> (String, String)? {
|
||||
// auth is a base64("username:password")
|
||||
guard let authBase64 = auth else {
|
||||
return nil
|
||||
}
|
||||
guard let data = Data(base64Encoded: authBase64) else {
|
||||
return nil
|
||||
}
|
||||
guard let components = String(data: data, encoding: .utf8)?.components(separatedBy: ":") else {
|
||||
return nil
|
||||
}
|
||||
if components.count != 2 {
|
||||
return nil
|
||||
}
|
||||
return (components[0], components[1])
|
||||
}
|
||||
}
|
||||
|
||||
struct DockerGetOutput: Codable {
|
||||
var Username: String
|
||||
var Secret: String
|
||||
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
import System
|
||||
|
||||
enum FileLockError: Error, Equatable {
|
||||
case Failed(_ message: String)
|
||||
case AlreadyLocked
|
||||
}
|
||||
|
||||
class FileLock {
|
||||
let url: URL
|
||||
let fd: Int32
|
||||
|
||||
init(lockURL: URL) throws {
|
||||
url = lockURL
|
||||
fd = open(lockURL.path, 0)
|
||||
}
|
||||
|
||||
deinit {
|
||||
close(fd)
|
||||
}
|
||||
|
||||
func trylock() throws -> Bool {
|
||||
try flockWrapper(LOCK_EX | LOCK_NB)
|
||||
}
|
||||
|
||||
func lock() throws {
|
||||
_ = try flockWrapper(LOCK_EX)
|
||||
}
|
||||
|
||||
func unlock() throws {
|
||||
_ = try flockWrapper(LOCK_UN)
|
||||
}
|
||||
|
||||
func flockWrapper(_ operation: Int32) throws -> Bool {
|
||||
let ret = flock(fd, operation)
|
||||
if ret != 0 {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
if (operation & LOCK_NB) != 0 && details == .wouldBlock {
|
||||
return false
|
||||
}
|
||||
|
||||
throw FileLockError.Failed("failed to lock \(url): \(details)")
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ class IPSWCache: PrunableStorage {
|
||||
let baseURL: URL
|
||||
|
||||
init() throws {
|
||||
baseURL = Config().tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
|
||||
baseURL = try Config().tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import Virtualization
|
||||
|
||||
protocol Network {
|
||||
func attachment() -> VZNetworkDeviceAttachment
|
||||
func run() throws
|
||||
func stop() throws
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
class NetworkBridged: Network {
|
||||
let interface: VZBridgedNetworkInterface
|
||||
|
||||
init(interface: VZBridgedNetworkInterface) {
|
||||
self.interface = interface
|
||||
}
|
||||
|
||||
func attachment() -> VZNetworkDeviceAttachment {
|
||||
VZBridgedNetworkDeviceAttachment(interface: interface)
|
||||
}
|
||||
|
||||
func run() throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
|
||||
func stop() throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
class NetworkShared: Network {
|
||||
func attachment() -> VZNetworkDeviceAttachment {
|
||||
VZNATNetworkDeviceAttachment()
|
||||
}
|
||||
|
||||
func run() throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
|
||||
func stop() throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
enum SoftnetError: Error {
|
||||
case InitializationFailed(why: String)
|
||||
}
|
||||
|
||||
class Softnet {
|
||||
class Softnet: Network {
|
||||
private let process = Process()
|
||||
|
||||
let vmFD: Int32
|
||||
@@ -57,4 +58,9 @@ class Softnet {
|
||||
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)")
|
||||
}
|
||||
}
|
||||
|
||||
func attachment() -> VZNetworkDeviceAttachment {
|
||||
let fh = FileHandle.init(fileDescriptor: vmFD)
|
||||
return VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ class Registry {
|
||||
|
||||
init(urlComponents: URLComponents,
|
||||
namespace: String,
|
||||
credentialsProviders: [CredentialsProvider] = [HelperProgramCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
) throws {
|
||||
baseURL = urlComponents.url!
|
||||
self.namespace = namespace
|
||||
@@ -105,7 +105,7 @@ class Registry {
|
||||
host: String,
|
||||
namespace: String,
|
||||
insecure: Bool = false,
|
||||
credentialsProviders: [CredentialsProvider] = [HelperProgramCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
) throws {
|
||||
let proto = insecure ? "http" : "https"
|
||||
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
||||
|
||||
@@ -17,6 +17,7 @@ struct Root: AsyncParsableCommand {
|
||||
Pull.self,
|
||||
Push.self,
|
||||
Prune.self,
|
||||
Rename.self,
|
||||
Delete.self,
|
||||
])
|
||||
|
||||
@@ -36,6 +37,9 @@ struct Root: AsyncParsableCommand {
|
||||
do {
|
||||
var command = try parseAsRoot()
|
||||
|
||||
// Run garbage-collection before each command (shouldn't take too long)
|
||||
try Config().gc()
|
||||
|
||||
if var asyncCommand = command as? AsyncParsableCommand {
|
||||
try await asyncCommand.run()
|
||||
} else {
|
||||
|
||||
+12
-28
@@ -14,7 +14,7 @@ struct UnsupportedOSError: Error, CustomStringConvertible {
|
||||
let description: String
|
||||
|
||||
init(_ what: String, _ plural: String) {
|
||||
description = "error: \(what) \(plural) only supported on macOS 13.0 (Ventura) or newer"
|
||||
description = "error: \(what) \(plural) only supported on hosts running macOS 13.0 (Ventura) or newer"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,10 +34,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// VM's config
|
||||
var config: VMConfig
|
||||
|
||||
var softnet: Softnet? = nil
|
||||
var network: Network
|
||||
|
||||
init(vmDir: VMDirectory,
|
||||
withSoftnet: Bool = false,
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
||||
directoryShares: [DirectoryShare] = []
|
||||
) throws {
|
||||
@@ -49,13 +49,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
if withSoftnet {
|
||||
softnet = try Softnet(vmMACAddress: config.macAddress.string)
|
||||
}
|
||||
|
||||
self.network = network
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
||||
nvramURL: vmDir.nvramURL, vmConfig: config,
|
||||
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments,
|
||||
network: network, additionalDiskAttachments: additionalDiskAttachments,
|
||||
directoryShares: directoryShares)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
@@ -114,7 +111,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
vmDir: VMDirectory,
|
||||
ipswURL: URL?,
|
||||
diskSizeGB: UInt16,
|
||||
withSoftnet: Bool = false,
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
|
||||
) async throws {
|
||||
let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW();
|
||||
@@ -149,12 +146,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
try config.save(toURL: vmDir.configURL)
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
if withSoftnet {
|
||||
softnet = try Softnet(vmMACAddress: config.macAddress.string)
|
||||
}
|
||||
|
||||
self.network = network
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||
vmConfig: config, softnet: softnet,
|
||||
vmConfig: config, network: network,
|
||||
additionalDiskAttachments: additionalDiskAttachments,
|
||||
directoryShares: [])
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
@@ -193,9 +187,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
func run(_ recovery: Bool) async throws {
|
||||
if let softnet = softnet {
|
||||
try softnet.run()
|
||||
}
|
||||
try network.run()
|
||||
|
||||
DispatchQueue.main.sync {
|
||||
Task {
|
||||
@@ -225,16 +217,14 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
if let softnet = softnet {
|
||||
try softnet.stop();
|
||||
}
|
||||
try network.stop()
|
||||
}
|
||||
|
||||
static func craftConfiguration(
|
||||
diskURL: URL,
|
||||
nvramURL: URL,
|
||||
vmConfig: VMConfig,
|
||||
softnet: Softnet? = nil,
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
|
||||
directoryShares: [DirectoryShare]
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
@@ -268,13 +258,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
// Networking
|
||||
let vio = VZVirtioNetworkDeviceConfiguration()
|
||||
|
||||
if let softnet = softnet {
|
||||
let fh = FileHandle.init(fileDescriptor: softnet.vmFD)
|
||||
vio.attachment = VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
|
||||
} else {
|
||||
vio.attachment = VZNATNetworkDeviceAttachment()
|
||||
}
|
||||
vio.attachment = network.attachment()
|
||||
vio.macAddress = vmConfig.macAddress
|
||||
configuration.networkDevices = [vio]
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ struct VMDirectory: Prunable {
|
||||
}
|
||||
|
||||
static func temporary() throws -> VMDirectory {
|
||||
let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
let tmpDir = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: false)
|
||||
|
||||
return VMDirectory(baseURL: tmpDir)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
class VMStorageLocal {
|
||||
let baseURL: URL = Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
||||
let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
||||
|
||||
private func vmURL(_ name: String) -> URL {
|
||||
baseURL.appendingPathComponent(name, isDirectory: true)
|
||||
@@ -32,6 +32,10 @@ class VMStorageLocal {
|
||||
_ = try FileManager.default.replaceItemAt(vmURL(name), withItemAt: from.baseURL)
|
||||
}
|
||||
|
||||
func rename(_ name: String, _ newName: String) throws {
|
||||
_ = try FileManager.default.replaceItemAt(vmURL(newName), withItemAt: vmURL(name))
|
||||
}
|
||||
|
||||
func delete(_ name: String) throws {
|
||||
try FileManager.default.removeItem(at: vmURL(name))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
class VMStorageOCI: PrunableStorage {
|
||||
let baseURL = Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||
|
||||
private func vmURL(_ name: RemoteName) -> URL {
|
||||
baseURL.appendingRemoteName(name)
|
||||
}
|
||||
|
||||
private func hostDirectoryURL(_ name: RemoteName) -> URL {
|
||||
baseURL.appendingHost(name)
|
||||
}
|
||||
|
||||
func exists(_ name: RemoteName) -> Bool {
|
||||
VMDirectory(baseURL: vmURL(name)).initialized
|
||||
}
|
||||
@@ -125,17 +129,41 @@ class VMStorageOCI: PrunableStorage {
|
||||
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
||||
reference: Reference(digest: Digest.hash(manifestData)))
|
||||
|
||||
// Ensure that host directory for given RemoteName exists in OCI storage
|
||||
let hostDirectoryURL = hostDirectoryURL(digestName)
|
||||
try FileManager.default.createDirectory(at: hostDirectoryURL, withIntermediateDirectories: true)
|
||||
|
||||
// Acquire a lock on it to prevent concurrent pulls for a single host
|
||||
let lock = try FileLock(lockURL: hostDirectoryURL)
|
||||
|
||||
let sucessfullyLocked = try lock.trylock()
|
||||
if !sucessfullyLocked {
|
||||
print("waiting for lock...")
|
||||
try lock.lock()
|
||||
}
|
||||
defer { try! lock.unlock() }
|
||||
|
||||
if Task.isCancelled {
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
if !exists(digestName) {
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
try tmpVMDirLock.lock()
|
||||
|
||||
// Try to reclaim some cache space if we know the VM size in advance
|
||||
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
|
||||
let requiredCapacityBytes = UInt64(uncompressedDiskSize + 128 * 1024 * 1024)
|
||||
|
||||
let attrs = try tmpVMDir.baseURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
|
||||
let availableCapacityBytes = UInt64(attrs.volumeAvailableCapacityForImportantUsage!)
|
||||
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityKey])
|
||||
let availableCapacityBytes = max(UInt64(attrs.volumeAvailableCapacityForImportantUsage!), UInt64(attrs.volumeAvailableCapacity!))
|
||||
|
||||
if availableCapacityBytes < requiredCapacityBytes {
|
||||
// There is a suspicious that occasionally capacity is returned as zero which can't be true.
|
||||
// Let's validate to avoid unnecessary pruning.
|
||||
if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes {
|
||||
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
|
||||
}
|
||||
}
|
||||
@@ -181,4 +209,8 @@ extension URL {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func appendingHost(_ name: RemoteName) -> URL {
|
||||
self.appendingPathComponent(name.host, isDirectory: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class FileLockTests: XCTestCase {
|
||||
func testSimple() throws {
|
||||
// Create a temporary file that will be used as a lock
|
||||
let url = temporaryFile()
|
||||
|
||||
// Make sure this file can be locked and unlocked
|
||||
let lock = try FileLock(lockURL: url)
|
||||
try lock.lock()
|
||||
try lock.unlock()
|
||||
}
|
||||
|
||||
func testDoubleLockResultsInError() throws {
|
||||
// Create a temporary file that will be used as a lock
|
||||
let url = temporaryFile()
|
||||
|
||||
// Create two locks on a same file and ensure one of them fails
|
||||
let firstLock = try FileLock(lockURL: url)
|
||||
try firstLock.lock()
|
||||
|
||||
let secondLock = try! FileLock(lockURL: url)
|
||||
XCTAssertFalse(try secondLock.trylock())
|
||||
}
|
||||
|
||||
private func temporaryFile() -> URL {
|
||||
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
|
||||
|
||||
FileManager.default.createFile(atPath: url.path, contents: nil)
|
||||
|
||||
return url
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user