mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-11 16:35:33 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bb248e7b4 | ||
|
|
f45551cbf0 | ||
|
|
f68297097e | ||
|
|
637a2387e7 | ||
|
|
050d6a6ff1 | ||
|
|
5eddd1ce41 | ||
|
|
35f5b30bc4 | ||
|
|
8b27fea745 | ||
|
|
e00f62c95a | ||
|
|
d90893e9a0 | ||
|
|
feb733a7c0 | ||
|
|
545b6fcd94 | ||
|
|
c750d63ac9 | ||
|
|
704811e671 | ||
|
|
d4fcecd47c | ||
|
|
33ca96e1a0 | ||
|
|
4ce06279ff | ||
|
|
fa97adfc9e | ||
|
|
6c377029d6 | ||
|
|
93a1b70ecb |
+1
-1
@@ -1,7 +1,7 @@
|
||||
use_compute_credits: true
|
||||
|
||||
env:
|
||||
XCODE_TAG: 15-beta-2
|
||||
XCODE_TAG: 15-beta-5
|
||||
|
||||
task:
|
||||
name: Test on Ventura
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ brews:
|
||||
libexec.install Dir["*"]
|
||||
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
|
||||
custom_block: |
|
||||
depends_on :macos => :monterey
|
||||
depends_on :macos => :ventura
|
||||
|
||||
on_macos do
|
||||
unless Hardware::CPU.arm?
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Contributing to Tart
|
||||
|
||||
Table of Contents
|
||||
-----------------
|
||||
|
||||
- [How to Build](#how-to-build)
|
||||
- [How to Create an Issue/Enhancement](#how-to-create-an-issueenhancement)
|
||||
- [Style Guidelines](#style-guidelines)
|
||||
- [Pull Requests](#Pull-Requests)
|
||||
|
||||
## How to Build
|
||||
|
||||
1. Fork the repository to your own GitHub account
|
||||
2. Clone the forked repository to your local machine
|
||||
3. If using Xcode, use from Xcode 15 or newer
|
||||
4. Run ./scripts/run-signed.sh from the root of your repository
|
||||
|
||||
```bash
|
||||
./scripts/run-signed.sh list
|
||||
```
|
||||
## How to Create an Issue/Enhancement
|
||||
|
||||
1. Go to the [Issue page](https://github.com/cirruslabs/tart/issues) of the repository
|
||||
2. Click on the "New Issue" button
|
||||
3. Provide a descriptive title and detailed description of the issue or enhancement you're suggesting
|
||||
4. Submit the issue
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
1. Code should follow camel case
|
||||
2. Code should follow [SwiftFormat](https://github.com/nicklockwood/SwiftFormat#swift-package-manager-plugin) guidelines. You can auto-format the code by running the following command:
|
||||
```bash
|
||||
swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore .
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
1. Provide a detailed description of the changes you made in the pull request
|
||||
2. Wait for pull request to be reviewed
|
||||
3. Make adjustments if necessary
|
||||
+1
-1
@@ -4,7 +4,7 @@ import PackageDescription
|
||||
let package = Package(
|
||||
name: "Tart",
|
||||
platforms: [
|
||||
.macOS(.v12)
|
||||
.macOS(.v13)
|
||||
],
|
||||
products: [
|
||||
.executable(name: "tart", targets: ["tart"])
|
||||
|
||||
@@ -6,9 +6,18 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
|
||||
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/20382844?baseline=20382722).
|
||||
* Push/Pull virtual machines from any OCI-compatible container registry.
|
||||
* Use Tart Packer Plugin to automate VM creation.
|
||||
* Built-in CI integration.
|
||||
* Easily integrates with any CI system.
|
||||
|
||||
*Tart* is already adopted by several automation services:
|
||||
Tart powers [Cirrus Runners](https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral)
|
||||
service — a drop-in replacement for the standard GitHub-hosted runners, offering 2-3 times better performance for a fraction of the price.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/CirrusRunnersForGHA.png" height="65"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Tart is also adopted by several other automation services:
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
|
||||
@@ -52,7 +61,7 @@ Many more companies are using Tart in their internal setups. Here are a few of t
|
||||
|
||||
## Usage
|
||||
|
||||
Try running a Tart VM on your Apple Silicon device running macOS 12.0 (Monterey) or later (will download a 25 GB image):
|
||||
Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura) or later (will download a 25 GB image):
|
||||
|
||||
```bash
|
||||
brew install cirruslabs/cli/tart
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -3,7 +3,20 @@ import Foundation
|
||||
import SystemConfiguration
|
||||
|
||||
struct Clone: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Clone a VM")
|
||||
static var configuration = CommandConfiguration(
|
||||
abstract: "Clone a VM",
|
||||
discussion: """
|
||||
Creates a local virtual machine by cloning either a remote or another local virtual machine.
|
||||
|
||||
Due to copy-on-write magic in Apple File System a cloned VM won't actually claim all the space right away.
|
||||
Only changes to a cloned disk will be written and claim new space. By default, Tart checks available capacity
|
||||
in Tart's home directory and checks if there is enough space for the worst possible scenario: when the whole disk
|
||||
will be modified.
|
||||
|
||||
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable. This might be helpful
|
||||
for use cases when the original image is very big and a workload is known to only modify a fraction of the cloned disk.
|
||||
"""
|
||||
)
|
||||
|
||||
@Argument(help: "source VM name")
|
||||
var sourceName: String
|
||||
@@ -53,7 +66,7 @@ struct Clone: AsyncParsableCommand {
|
||||
// APFS is doing copy-on-write so the above cloning operation (just copying files on disk)
|
||||
// is not actually claiming new space until the VM is started and it writes something to disk.
|
||||
// So once we clone the VM let's try to claim a little bit of space for the VM to run.
|
||||
try Prune.reclaimIfNeeded(UInt64(sourceVM.sizeBytes()))
|
||||
try Prune.reclaimIfNeeded(UInt64(sourceVM.sizeBytes()), sourceVM)
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
|
||||
@@ -47,11 +47,7 @@ struct Create: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
if linux {
|
||||
if #available(macOS 13, *) {
|
||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
||||
} else {
|
||||
throw UnsupportedOSError("Linux VMs", "are")
|
||||
}
|
||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
||||
}
|
||||
|
||||
try VMStorageLocal().move(name, from: tmpVMDir)
|
||||
|
||||
@@ -17,6 +17,9 @@ struct Login: AsyncParsableCommand {
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
var insecure: Bool = false
|
||||
|
||||
@Flag(help: "skip validation of the registry's credentials before logging-in")
|
||||
var noValidate: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
let usernameProvided = username != nil
|
||||
let passwordProvided = passwordStdin
|
||||
@@ -45,12 +48,14 @@ struct Login: AsyncParsableCommand {
|
||||
host: (user, password)
|
||||
])
|
||||
|
||||
do {
|
||||
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
||||
credentialsProviders: [credentialsProvider])
|
||||
try await registry.ping()
|
||||
} catch {
|
||||
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
|
||||
if !noValidate {
|
||||
do {
|
||||
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
||||
credentialsProviders: [credentialsProvider])
|
||||
try await registry.ping()
|
||||
} catch {
|
||||
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
|
||||
struct Logout: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Logout from a registry")
|
||||
|
||||
@Argument(help: "host")
|
||||
var host: String
|
||||
|
||||
func run() async throws {
|
||||
try KeychainCredentialsProvider().remove(host: host)
|
||||
}
|
||||
}
|
||||
@@ -5,23 +5,40 @@ import SwiftUI
|
||||
import SwiftDate
|
||||
|
||||
struct Prune: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches")
|
||||
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches or local VMs")
|
||||
|
||||
@Option(help: ArgumentHelp("Remove cache entries last accessed more than n days ago",
|
||||
@Option(help: ArgumentHelp("Entries to remove: \"caches\" targets OCI and IPSW caches and \"vms\" targets local VMs."))
|
||||
var entries: String = "caches"
|
||||
|
||||
@Option(help: ArgumentHelp("Remove entries that were last accessed more than n days ago",
|
||||
discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.",
|
||||
valueName: "n"))
|
||||
var olderThan: UInt?
|
||||
|
||||
@Option(help: ArgumentHelp("Remove least recently used cache entries that do not fit the specified cache size budget n, expressed in gigabytes",
|
||||
discussion: "For example, --cache-budget=50 will effectively shrink all caches to a total size of 50 gigabytes.",
|
||||
valueName: "n"))
|
||||
@Option(help: .hidden)
|
||||
var cacheBudget: UInt?
|
||||
|
||||
@Option(help: ArgumentHelp("Remove the least recently used entries that do not fit the specified space size budget n, expressed in gigabytes",
|
||||
discussion: "For example, --space-budget=50 will effectively shrink all entries to a total size of 50 gigabytes.",
|
||||
valueName: "n"))
|
||||
var spaceBudget: UInt?
|
||||
|
||||
@Flag(help: .hidden)
|
||||
var gc: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
if olderThan == nil && cacheBudget == nil && !gc {
|
||||
mutating func validate() throws {
|
||||
// --cache-budget deprecation logic
|
||||
if let cacheBudget = cacheBudget {
|
||||
fputs("--cache-budget is deprecated, please use --space-budget\n", stderr)
|
||||
|
||||
if spaceBudget != nil {
|
||||
throw ValidationError("--cache-budget is deprecated, please use --space-budget")
|
||||
}
|
||||
|
||||
spaceBudget = cacheBudget
|
||||
}
|
||||
|
||||
if olderThan == nil && spaceBudget == nil && !gc {
|
||||
throw ValidationError("at least one pruning criteria must be specified")
|
||||
}
|
||||
}
|
||||
@@ -31,43 +48,53 @@ struct Prune: AsyncParsableCommand {
|
||||
try VMStorageOCI().gc()
|
||||
}
|
||||
|
||||
// Build a list of prunable storages that we're going to prune based on user's request
|
||||
let prunableStorages: [PrunableStorage]
|
||||
|
||||
switch entries {
|
||||
case "caches":
|
||||
prunableStorages = [VMStorageOCI(), try IPSWCache()]
|
||||
case "vms":
|
||||
prunableStorages = [VMStorageLocal()]
|
||||
default:
|
||||
throw ValidationError("unsupported --entries value, please specify either \"caches\" or \"vms\"")
|
||||
}
|
||||
|
||||
// Clean up cache entries based on last accessed date
|
||||
if let olderThan = olderThan {
|
||||
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
|
||||
let olderThanDate = Date() - olderThanInterval
|
||||
|
||||
try Prune.pruneOlderThan(olderThanDate: olderThanDate)
|
||||
try Prune.pruneOlderThan(prunableStorages: prunableStorages, olderThanDate: olderThanDate)
|
||||
}
|
||||
|
||||
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
|
||||
if let cacheBudget = cacheBudget {
|
||||
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024)
|
||||
if let spaceBudget = spaceBudget {
|
||||
try Prune.pruneSpaceBudget(prunableStorages: prunableStorages, spaceBudgetBytes: UInt64(spaceBudget) * 1024 * 1024 * 1024)
|
||||
}
|
||||
}
|
||||
|
||||
static func pruneOlderThan(olderThanDate: Date) throws {
|
||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||
static func pruneOlderThan(prunableStorages: [PrunableStorage], olderThanDate: Date) throws {
|
||||
let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables() }
|
||||
|
||||
try prunables.filter { try $0.accessDate() <= olderThanDate }.forEach { try $0.delete() }
|
||||
}
|
||||
|
||||
static func pruneCacheBudget(cacheBudgetBytes: UInt64) throws {
|
||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||
static func pruneSpaceBudget(prunableStorages: [PrunableStorage], spaceBudgetBytes: UInt64) throws {
|
||||
let prunables: [Prunable] = try prunableStorages
|
||||
.flatMap { try $0.prunables() }
|
||||
.sorted { try $0.accessDate() > $1.accessDate() }
|
||||
|
||||
var cacheBudgetBytes = cacheBudgetBytes
|
||||
var spaceBudgetBytes = spaceBudgetBytes
|
||||
var prunablesToDelete: [Prunable] = []
|
||||
|
||||
for prunable in prunables {
|
||||
let prunableSizeBytes = UInt64(try prunable.sizeBytes())
|
||||
|
||||
if prunableSizeBytes <= cacheBudgetBytes {
|
||||
if prunableSizeBytes <= spaceBudgetBytes {
|
||||
// Don't mark for deletion as
|
||||
// there's a budget available
|
||||
cacheBudgetBytes -= prunableSizeBytes
|
||||
spaceBudgetBytes -= prunableSizeBytes
|
||||
} else {
|
||||
// Mark for deletion
|
||||
prunablesToDelete.append(prunable)
|
||||
@@ -77,7 +104,11 @@ struct Prune: AsyncParsableCommand {
|
||||
try prunablesToDelete.forEach { try $0.delete() }
|
||||
}
|
||||
|
||||
static func reclaimIfNeeded(_ requiredBytes: UInt64) throws {
|
||||
static func reclaimIfNeeded(_ requiredBytes: UInt64, _ initiator: Prunable? = nil) throws {
|
||||
if ProcessInfo.processInfo.environment.keys.contains("TART_NO_AUTO_PRUNE") {
|
||||
return
|
||||
}
|
||||
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setContext(value: ["requiredBytes": requiredBytes], key: "Prune")
|
||||
}
|
||||
@@ -114,10 +145,10 @@ struct Prune: AsyncParsableCommand {
|
||||
return
|
||||
}
|
||||
|
||||
try Prune.reclaimIfPossible(requiredBytes - volumeAvailableCapacityCalculated)
|
||||
try Prune.reclaimIfPossible(requiredBytes - volumeAvailableCapacityCalculated, initiator)
|
||||
}
|
||||
|
||||
private static func reclaimIfPossible(_ reclaimBytes: UInt64) throws {
|
||||
private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
|
||||
let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
|
||||
defer { transaction.finish() }
|
||||
|
||||
@@ -141,6 +172,11 @@ struct Prune: AsyncParsableCommand {
|
||||
break
|
||||
}
|
||||
|
||||
if prunable.url == initiator?.url.resolvingSymlinksInPath() {
|
||||
// do not prune the initiator
|
||||
continue
|
||||
}
|
||||
|
||||
try SentrySDK.span?.setData(value: prunable.sizeBytes(), key: prunable.url.path)
|
||||
|
||||
cacheReclaimedBytes += try prunable.sizeBytes()
|
||||
|
||||
@@ -3,7 +3,16 @@ import Dispatch
|
||||
import SwiftUI
|
||||
|
||||
struct Pull: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Pull a VM from a registry")
|
||||
static var configuration = CommandConfiguration(
|
||||
abstract: "Pull a VM from a registry",
|
||||
discussion: """
|
||||
Pulls a virtual machine from a remote OCI-compatible registry. Supports authorization via Keychain (see "tart login --help"),
|
||||
Docker credential helpers defined in ~/.docker/config.json or via TART_REGISTRY_USERNAME/TART_REGISTRY_PASSWORD environment variables.
|
||||
|
||||
By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the remote image to fit via "tart prune".
|
||||
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
|
||||
"""
|
||||
)
|
||||
|
||||
@Argument(help: "remote VM name")
|
||||
var remoteName: String
|
||||
|
||||
@@ -91,7 +91,7 @@ struct Run: AsyncParsableCommand {
|
||||
""", discussion: """
|
||||
Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces.
|
||||
""", valueName: "interface name"))
|
||||
var netBridged: String?
|
||||
var netBridged: [String] = []
|
||||
|
||||
@Flag(help: ArgumentHelp("Use software networking instead of the default shared (NAT) networking",
|
||||
discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
|
||||
@@ -105,7 +105,7 @@ struct Run: AsyncParsableCommand {
|
||||
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||
}
|
||||
|
||||
if netBridged != nil && netSoftnet {
|
||||
if netBridged.count > 0 && netSoftnet {
|
||||
throw ValidationError("--net-bridged and --net-softnet are mutually exclusive")
|
||||
}
|
||||
|
||||
@@ -220,17 +220,6 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
var resume = false
|
||||
|
||||
if #available(macOS 14, *) {
|
||||
@@ -243,7 +232,20 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
try await vm!.run(recovery: recovery, resume: resume)
|
||||
try await vm!.start(recovery: recovery, resume: resume)
|
||||
|
||||
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()
|
||||
|
||||
if let vncImpl = vncImpl {
|
||||
try vncImpl.stop()
|
||||
@@ -329,23 +331,19 @@ struct Run: AsyncParsableCommand {
|
||||
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 netBridged.count > 0 {
|
||||
func findBridgedInterface(_ name: String) throws -> VZBridgedNetworkInterface {
|
||||
let interface = VZBridgedNetworkInterface.networkInterfaces.first { interface in
|
||||
interface.identifier == name || interface.localizedDisplayName == name
|
||||
}
|
||||
if (interface == nil) {
|
||||
throw ValidationError("no bridge interfaces matched \"\(netBridged)\", "
|
||||
+ "available interfaces: \(bridgeInterfaces())")
|
||||
}
|
||||
return interface!
|
||||
}
|
||||
|
||||
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 NetworkBridged(interfaces: try netBridged.map { try findBridgedInterface($0) })
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -11,7 +11,7 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
||||
if let credentialsFromAuth = config.auths?[host]?.decodeCredentials() {
|
||||
return credentialsFromAuth
|
||||
}
|
||||
if let helperProgram = config.credHelpers?[host] {
|
||||
if let helperProgram = try config.findCredHelper(host: host) {
|
||||
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
|
||||
}
|
||||
|
||||
@@ -59,6 +59,26 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
||||
struct DockerConfig: Codable {
|
||||
var auths: Dictionary<String, DockerAuthConfig>? = Dictionary()
|
||||
var credHelpers: Dictionary<String, String>? = Dictionary()
|
||||
|
||||
func findCredHelper(host: String) throws -> String? {
|
||||
// Tart supports wildcards in credHelpers
|
||||
// Similar to what is requested from Docker: https://github.com/docker/cli/issues/2928
|
||||
|
||||
guard let credHelpers else {
|
||||
return nil
|
||||
}
|
||||
|
||||
for (hostPattern, helperProgram) in credHelpers {
|
||||
if (hostPattern == host) {
|
||||
return helperProgram
|
||||
}
|
||||
let compiledPattern = try? Regex(hostPattern)
|
||||
if (try compiledPattern?.wholeMatch(in: host) != nil) {
|
||||
return helperProgram
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct DockerAuthConfig: Codable {
|
||||
|
||||
@@ -61,6 +61,24 @@ class KeychainCredentialsProvider: CredentialsProvider {
|
||||
throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
|
||||
}
|
||||
}
|
||||
|
||||
func remove(host: String) throws {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
||||
kSecAttrServer as String: host,
|
||||
kSecAttrLabel as String: "Tart Credentials",
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
return
|
||||
case errSecItemNotFound:
|
||||
return
|
||||
default:
|
||||
throw CredentialsProviderError.Failed(message: "Failed to remove Keychain item(s): \(status.explanation())")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension OSStatus {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Virtualization
|
||||
|
||||
protocol Network {
|
||||
func attachment() -> VZNetworkDeviceAttachment
|
||||
func attachments() -> [VZNetworkDeviceAttachment]
|
||||
func run(_ sema: DispatchSemaphore) throws
|
||||
func stop() async throws
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@ import Foundation
|
||||
import Virtualization
|
||||
|
||||
class NetworkBridged: Network {
|
||||
let interface: VZBridgedNetworkInterface
|
||||
let interfaces: [VZBridgedNetworkInterface]
|
||||
|
||||
init(interface: VZBridgedNetworkInterface) {
|
||||
self.interface = interface
|
||||
init(interfaces: [VZBridgedNetworkInterface]) {
|
||||
self.interfaces = interfaces
|
||||
}
|
||||
|
||||
func attachment() -> VZNetworkDeviceAttachment {
|
||||
VZBridgedNetworkDeviceAttachment(interface: interface)
|
||||
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||
interfaces.map { VZBridgedNetworkDeviceAttachment(interface: $0) }
|
||||
}
|
||||
|
||||
func run(_ sema: DispatchSemaphore) throws {
|
||||
|
||||
@@ -2,8 +2,8 @@ import Foundation
|
||||
import Virtualization
|
||||
|
||||
class NetworkShared: Network {
|
||||
func attachment() -> VZNetworkDeviceAttachment {
|
||||
VZNATNetworkDeviceAttachment()
|
||||
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||
[VZNATNetworkDeviceAttachment()]
|
||||
}
|
||||
|
||||
func run(_ sema: DispatchSemaphore) throws {
|
||||
|
||||
@@ -92,9 +92,9 @@ class Softnet: Network {
|
||||
}
|
||||
}
|
||||
|
||||
func attachment() -> VZNetworkDeviceAttachment {
|
||||
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||
let fh = FileHandle.init(fileDescriptor: vmFD)
|
||||
return VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
|
||||
return [VZFileHandleNetworkDeviceAttachment(fileHandle: fh)]
|
||||
}
|
||||
|
||||
static func configureSUIDBitIfNeeded() throws {
|
||||
|
||||
@@ -116,19 +116,11 @@ struct Darwin: PlatformSuspendable {
|
||||
}
|
||||
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 13, *) {
|
||||
// Trackpad is only supported by guests starting with macOS Ventura
|
||||
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
} else {
|
||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
// Trackpad is only supported by guests starting with macOS Ventura
|
||||
[VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 13, *) {
|
||||
return [VZMacTrackpadConfiguration()]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
[VZMacTrackpadConfiguration()]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ struct Root: AsyncParsableCommand {
|
||||
Get.self,
|
||||
List.self,
|
||||
Login.self,
|
||||
Logout.self,
|
||||
IP.self,
|
||||
Pull.self,
|
||||
Push.self,
|
||||
@@ -81,10 +82,12 @@ struct Root: AsyncParsableCommand {
|
||||
var command = try parseAsRoot()
|
||||
|
||||
// Run garbage-collection before each command (shouldn't take too long)
|
||||
do {
|
||||
try Config().gc()
|
||||
} catch {
|
||||
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
|
||||
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){
|
||||
do {
|
||||
try Config().gc()
|
||||
} catch {
|
||||
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
if var asyncCommand = command as? AsyncParsableCommand {
|
||||
|
||||
+20
-22
@@ -225,7 +225,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
return try VM(vmDir: vmDir)
|
||||
}
|
||||
|
||||
func run(recovery: Bool, resume shouldResume: Bool) async throws {
|
||||
func start(recovery: Bool, resume shouldResume: Bool) async throws {
|
||||
try network.run(sema)
|
||||
|
||||
if shouldResume {
|
||||
@@ -233,7 +233,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
} else {
|
||||
try await start(recovery)
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
await withTaskCancellationHandler(operation: {
|
||||
// Wait for the VM to finish running
|
||||
// or for the exit condition
|
||||
@@ -251,15 +253,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
@MainActor
|
||||
private func start(_ recovery: Bool) async throws {
|
||||
if #available(macOS 13, *) {
|
||||
// new API introduced in Ventura
|
||||
let startOptions = VZMacOSVirtualMachineStartOptions()
|
||||
startOptions.startUpFromMacOSRecovery = recovery
|
||||
try await virtualMachine.start(options: startOptions)
|
||||
} else {
|
||||
// use method that also available on Monterey
|
||||
try await virtualMachine.start(recovery)
|
||||
}
|
||||
let startOptions = VZMacOSVirtualMachineStartOptions()
|
||||
startOptions.startUpFromMacOSRecovery = recovery
|
||||
try await virtualMachine.start(options: startOptions)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -318,15 +314,19 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
// Networking
|
||||
let vio = VZVirtioNetworkDeviceConfiguration()
|
||||
vio.attachment = network.attachment()
|
||||
vio.macAddress = vmConfig.macAddress
|
||||
configuration.networkDevices = [vio]
|
||||
configuration.networkDevices = network.attachments().map {
|
||||
let vio = VZVirtioNetworkDeviceConfiguration()
|
||||
vio.attachment = $0
|
||||
vio.macAddress = vmConfig.macAddress
|
||||
return vio
|
||||
}
|
||||
|
||||
// Storage
|
||||
var attachments = [try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)]
|
||||
attachments.append(contentsOf: additionalDiskAttachments)
|
||||
configuration.storageDevices = attachments.map { VZVirtioBlockDeviceConfiguration(attachment: $0) }
|
||||
configuration.storageDevices = attachments.map {
|
||||
VZVirtioBlockDeviceConfiguration(attachment: $0)
|
||||
}
|
||||
|
||||
// Entropy
|
||||
if !suspendable {
|
||||
@@ -343,15 +343,13 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
//
|
||||
// A dummy console device useful for implementing
|
||||
// host feature checks in the guest agent software.
|
||||
if #available(macOS 13, *) {
|
||||
let consolePort = VZVirtioConsolePortConfiguration()
|
||||
consolePort.name = "tart-version-\(CI.version)"
|
||||
let consolePort = VZVirtioConsolePortConfiguration()
|
||||
consolePort.name = "tart-version-\(CI.version)"
|
||||
|
||||
let consoleDevice = VZVirtioConsoleDeviceConfiguration()
|
||||
consoleDevice.ports[0] = consolePort
|
||||
let consoleDevice = VZVirtioConsoleDeviceConfiguration()
|
||||
consoleDevice.ports[0] = consolePort
|
||||
|
||||
configuration.consoleDevices.append(consoleDevice)
|
||||
}
|
||||
configuration.consoleDevices.append(consoleDevice)
|
||||
|
||||
try configuration.validate()
|
||||
|
||||
|
||||
@@ -97,11 +97,7 @@ struct VMConfig: Codable {
|
||||
case .darwin:
|
||||
platform = try Darwin(from: decoder)
|
||||
case .linux:
|
||||
if #available(macOS 13, *) {
|
||||
platform = try Linux(from: decoder)
|
||||
} else {
|
||||
throw UnsupportedOSError("Linux VMs", "are")
|
||||
}
|
||||
platform = try Linux(from: decoder)
|
||||
}
|
||||
cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin)
|
||||
cpuCount = try container.decode(Int.self, forKey: .cpuCount)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
import CryptoKit
|
||||
|
||||
struct VMDirectory: Prunable {
|
||||
var baseURL: URL
|
||||
@@ -59,6 +60,17 @@ struct VMDirectory: Prunable {
|
||||
return VMDirectory(baseURL: tmpDir)
|
||||
}
|
||||
|
||||
//Create tmp directory with hashing
|
||||
static func temporaryDeterministic(key: String) throws -> VMDirectory {
|
||||
let keyData = Data(key.utf8)
|
||||
let hash = Insecure.MD5.hash(data: keyData)
|
||||
// Convert hash to string
|
||||
let hashString = hash.compactMap { String(format: "%02x", $0) }.joined()
|
||||
let tmpDir = try Config().tartTmpDir.appendingPathComponent(hashString)
|
||||
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
|
||||
return VMDirectory(baseURL: tmpDir)
|
||||
}
|
||||
|
||||
var initialized: Bool {
|
||||
FileManager.default.fileExists(atPath: configURL.path) &&
|
||||
FileManager.default.fileExists(atPath: diskURL.path) &&
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
class VMStorageLocal {
|
||||
class VMStorageLocal: PrunableStorage {
|
||||
let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
||||
|
||||
private func vmURL(_ name: String) -> URL {
|
||||
@@ -16,6 +16,8 @@ class VMStorageLocal {
|
||||
|
||||
try vmDir.validate(userFriendlyName: name)
|
||||
|
||||
try vmDir.baseURL.updateAccessDate()
|
||||
|
||||
return vmDir
|
||||
}
|
||||
|
||||
@@ -63,6 +65,10 @@ class VMStorageLocal {
|
||||
}
|
||||
}
|
||||
|
||||
func prunables() throws -> [Prunable] {
|
||||
try list().map { (_, vmDir) in vmDir }
|
||||
}
|
||||
|
||||
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
|
||||
try list().contains { try $1.macAddress() == macAddress }
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
|
||||
if !exists(digestName) {
|
||||
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
let tmpVMDir = try VMDirectory.temporaryDeterministic(key: name.description)
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class DockerConfigTests: XCTestCase {
|
||||
func testHelpers() throws {
|
||||
let config = DockerConfig(credHelpers: [
|
||||
"(.*).dkr.ecr.(.*).amazonaws.com": "ecr-login",
|
||||
"gcr.io": "gcloud"
|
||||
])
|
||||
|
||||
XCTAssertEqual(try config.findCredHelper(host: "gcr.io"), "gcloud")
|
||||
XCTAssertEqual(try config.findCredHelper(host: "123.dkr.ecr.eu-west-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"))
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ task:
|
||||
name: hello
|
||||
macos_instance:
|
||||
# can be a remote or a local virtual machine
|
||||
image: ghcr.io/cirruslabs/macos-monterey-base:latest
|
||||
image: ghcr.io/cirruslabs/macos-ventura-base:latest
|
||||
hello_script:
|
||||
- echo "Hello from within a Tart VM!"
|
||||
- echo "Here is my CPU info:"
|
||||
@@ -45,7 +45,7 @@ exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tas
|
||||
task:
|
||||
name: Build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-monterey-xcode:latest
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||
build_script: swift build --product tart
|
||||
binary_artifacts:
|
||||
path: .build/debug/tart
|
||||
|
||||
@@ -28,5 +28,6 @@ jobs:
|
||||
```
|
||||
|
||||
When workflows are executing you'll see Cirrus on-demand runners on your organization's settings page at `https://github.com/organizations/<ORGANIZATION>/settings/actions/runners`.
|
||||
Note that Cirrus Runners will get added to the default runner group. By default, only private repositories can access runners in a default runner group, but you can override this in your organization's settings.
|
||||
|
||||

|
||||
|
||||
@@ -11,8 +11,8 @@ Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` fi
|
||||
use `latest` instead of a path to `*.ipsw` to download the latest available version:
|
||||
|
||||
```bash
|
||||
tart create --from-ipsw=latest monterey-vanilla
|
||||
tart run monterey-vanilla
|
||||
tart create --from-ipsw=latest ventura-vanilla
|
||||
tart run ventura-vanilla
|
||||
```
|
||||
|
||||
After the initial booting of the VM you'll need to manually go through the macOS installation process. As a convention we recommend creating an `admin` user with an `admin` password. After the regular installation please do some additional modifications in the VM:
|
||||
@@ -54,7 +54,7 @@ Please refer to `tart set --help` for additional details.
|
||||
## Building with Packer
|
||||
|
||||
Please refer to [Tart Packer Plugin repository](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
|
||||
Here is an example of a template to build `monterey-base` local image based of a remote image:
|
||||
Here is an example of a template to build a local image based of a remote image:
|
||||
|
||||
```hcl
|
||||
packer {
|
||||
|
||||
+42
-8
@@ -8,19 +8,53 @@ are licensed under [Fair Source License](https://fair.io/). Usage on personal co
|
||||
but organizations that exceed a certain number of server installations (100 CPU cores for Tart and/or 4 hosts for Orchard)
|
||||
will be required to obtain a paid license.
|
||||
|
||||
??? note "Performance and Efficiency Cores"
|
||||
The virtual CPU cores in Tart VMs do not differentiate between the high-performance and high-efficient cores
|
||||
of the host CPU. Instead, Tart VMs automatically alternate between these types of cores depending on the workload
|
||||
being executed within the virtual machines. As a result, both performance and energy-efficient cores of the host CPU
|
||||
are treated equally in terms of licensing.
|
||||
??? note "Host CPU Core usage"
|
||||
The virtual CPU cores of Tart VMs are not tied to specific physical cores of the host CPU. Instead, for optimal performance
|
||||
Tart VMs will automatically try to balance compute between all available cores of the host CPU. As a result,
|
||||
all performance and energy-efficient cores of the host CPU are always counted towards the license usage.
|
||||
|
||||
|
||||
# License Tiers
|
||||
|
||||
When an organization surpasses the 100 CPU cores limit, it is required to obtain a Gold Tier License, which costs \$1000 per month.
|
||||
Upon reaching a limit of 500 CPU cores, a Platinum Tier License (\$5000 per month) will be required, and for organizations
|
||||
that exceed 5000 CPU cores, a custom Diamond Tier License (\$1 per core per month) will be necessary.
|
||||
## Free Tier
|
||||
|
||||
By default, when no [license is purchased](#get-the-license), it is assumed that an organization is using a Free Tier license.
|
||||
You can find the Free Tier license text in [Tart](https://github.com/cirruslabs/tart/blob/main/LICENSE) and [Orchard](https://github.com/cirruslabs/orchard/blob/main/LICENSE) repositories.
|
||||
|
||||
Free Tier license has a 100 CPU core limit for Tart and 4 Orchard Workers limit for Orchard.
|
||||
|
||||
??? info "Usage Scenarios Examples"
|
||||
|
||||
Here are a few examples that fit into the free tier:
|
||||
|
||||
- Using Tart on 12 Mac Minis with 8 CPUs each running up to 24 VMs in parallel.
|
||||
- Creating an Orchard cluster of 4 Mac Studio workers with 24 CPUs each.
|
||||
|
||||
Here are a few examples that do not fit into the free tier:
|
||||
|
||||
- Using Tart on 13 Mac Minis with 8 CPUs each.
|
||||
- Creating an Orchard cluster of 5 Mac Minis workers with 8 CPUs each.
|
||||
|
||||
## Gold Tier
|
||||
|
||||
If an organization wishes to exceed the limits of the Free Tier license, a purchase of the [Gold Tier License](#get-the-license) is required, which costs \$1000 per month.
|
||||
|
||||
Gold Tier license has a 500 CPU core limit for Tart and 20 Orchard Workers limit for Orchard.
|
||||
|
||||
## Platinum Tier
|
||||
|
||||
If an organization wishes to exceed the limits of the Gold Tier license, a purchase of the [Platinum Tier License](#get-the-license) is required, which costs \$5000 per month.
|
||||
|
||||
Platinum Tier license has a 5,000 CPU core limit for Tart and 200 Orchard Workers limit for Orchard.
|
||||
|
||||
## Diamond Tier
|
||||
|
||||
For organizations that wish to exceed the limits of the Platinum Tier license, a purchase of a [custom Diamond Tier License](#get-the-license) is required, which costs \$1 per CPU core per month and gives the ability to run unlimited Orchard Workers.
|
||||
|
||||
# Get the license
|
||||
|
||||
If your organization is interested in purchasing one of the license tiers, please email [licensing@cirruslabs.org](mailto:licensing@cirruslabs.org).
|
||||
|
||||
You can see a template of a license subscription agreement [here](assets/TartLicenseSubscription.pdf).
|
||||
|
||||
# General Support
|
||||
|
||||
+12
-2
@@ -3,7 +3,7 @@ hide:
|
||||
- navigation
|
||||
---
|
||||
|
||||
Try running a Tart VM on your Apple Silicon device running macOS 12.0 (Monterey) or later (will download a 25 GB image):
|
||||
Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura) or later (will download a 25 GB image):
|
||||
|
||||
```bash
|
||||
brew install cirruslabs/cli/tart
|
||||
@@ -33,9 +33,19 @@ tart run ventura-base
|
||||
If the guest VM is running and configured to accept incoming SSH connections you can conveniently connect to it like so:
|
||||
|
||||
```bash
|
||||
ssh admin@$(tart ip macos-ventura-base)
|
||||
ssh admin@$(tart ip ventura-base)
|
||||
```
|
||||
|
||||
!!! tip "Running scripts inside Tart virtual machines"
|
||||
We recommend using [Cirrus CLI](integrations/cirrus-cli.md) to run scripts and/or retrieve artifacts
|
||||
from within Tart virtual machines. Alternatively, you can use plain ssh connection and `tart ip` command:
|
||||
|
||||
```bash
|
||||
brew install sshpass
|
||||
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip ventura-base) "uname -a"
|
||||
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip ventura-base) < script.sh
|
||||
```
|
||||
|
||||
## Mounting directories
|
||||
|
||||
To mount a directory, run the VM with the `--dir` argument:
|
||||
|
||||
Vendored
+1
-1
@@ -81,7 +81,7 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="https://unpkg.com/@dotlottie/player-component@latest/dist/dotlottie-player.js"></script>
|
||||
<script src="https://unpkg.com/@dotlottie/player-component@1.4.2/dist/dotlottie-player.js"></script>
|
||||
|
||||
<!-- landing page for landing page -->
|
||||
<!-- Hero -->
|
||||
|
||||
@@ -3,3 +3,4 @@ testcontainers
|
||||
requests
|
||||
bitmath
|
||||
pytest-dependency
|
||||
paramiko
|
||||
|
||||
@@ -7,10 +7,9 @@ class Tart:
|
||||
def __init__(self):
|
||||
self.tmp_dir = tempfile.TemporaryDirectory(dir=os.environ.get("CIRRUS_WORKING_DIR"))
|
||||
|
||||
# Link to the users IPSW cache to make things faster
|
||||
src = os.path.join(os.path.expanduser("~"), ".tart", "cache", "IPSWs")
|
||||
dst = os.path.join(self.tmp_dir.name, "cache", "IPSWs")
|
||||
os.makedirs(os.path.join(self.tmp_dir.name, "cache"))
|
||||
# Link to the users cache to make things faster
|
||||
src = os.path.join(os.path.expanduser("~"), ".tart", "cache")
|
||||
dst = os.path.join(self.tmp_dir.name, "cache")
|
||||
os.symlink(src, dst)
|
||||
|
||||
def __enter__(self):
|
||||
@@ -31,3 +30,9 @@ class Tart:
|
||||
completed_process.check_returncode()
|
||||
|
||||
return completed_process.stdout.decode("utf-8"), completed_process.stderr.decode("utf-8")
|
||||
|
||||
def run_async(self, args) -> subprocess.Popen:
|
||||
env = os.environ.copy()
|
||||
env.update({"TART_HOME": self.tmp_dir.name})
|
||||
|
||||
return subprocess.Popen(["tart"] + args, env=env)
|
||||
|
||||
@@ -6,5 +6,5 @@ def test_clone(tart):
|
||||
tart.run(["clone", "debian", "ubuntu"])
|
||||
|
||||
# Ensure that we have now 2 VMs
|
||||
stdout, _, = tart.run(["list", "--quiet"])
|
||||
stdout, _, = tart.run(["list", "--source", "local", "--quiet"])
|
||||
assert stdout == "debian\nubuntu\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ def test_create_macos(tart):
|
||||
tart.run(["create", "--from-ipsw", "latest", "macos-vm"])
|
||||
|
||||
# Ensure that the VM was created
|
||||
stdout, _ = tart.run(["list", "--quiet"])
|
||||
stdout, _ = tart.run(["list", "--source", "local", "--quiet"])
|
||||
assert stdout == "macos-vm\n"
|
||||
|
||||
|
||||
@@ -12,5 +12,5 @@ def test_create_linux(tart):
|
||||
tart.run(["create", "--linux", "linux-vm"])
|
||||
|
||||
# Ensure that the VM was created
|
||||
stdout, _ = tart.run(["list", "--quiet"])
|
||||
stdout, _ = tart.run(["list", "--source", "local", "--quiet"])
|
||||
assert stdout == "linux-vm\n"
|
||||
|
||||
@@ -3,12 +3,12 @@ def test_delete(tart):
|
||||
tart.run(["create", "--linux", "debian"])
|
||||
|
||||
# Ensure that the VM exists
|
||||
stdout, _, = tart.run(["list", "--quiet"])
|
||||
stdout, _, = tart.run(["list", "--source", "local", "--quiet"])
|
||||
assert stdout == "debian\n"
|
||||
|
||||
# Delete the VM
|
||||
tart.run(["delete", "debian"])
|
||||
|
||||
# Ensure that the VM was removed
|
||||
stdout, _, = tart.run(["list", "--quiet"])
|
||||
stdout, _, = tart.run(["list", "--source", "local", "--quiet"])
|
||||
assert stdout == ""
|
||||
|
||||
@@ -6,5 +6,5 @@ def test_rename(tart):
|
||||
tart.run(["rename", "debian", "ubuntu"])
|
||||
|
||||
# Ensure that the VM is now named "ubuntu"
|
||||
stdout, _, = tart.run(["list", "--quiet"])
|
||||
stdout, _, = tart.run(["list", "--source", "local", "--quiet"])
|
||||
assert stdout == "ubuntu\n"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import uuid
|
||||
|
||||
from paramiko.client import SSHClient, AutoAddPolicy
|
||||
|
||||
|
||||
def test_run(tart):
|
||||
vm_name = f"integration-test-run-{uuid.uuid4()}"
|
||||
|
||||
# Instantiate a VM with admin:admin SSH access
|
||||
tart.run(["clone", "ghcr.io/cirruslabs/macos-ventura-base:latest", vm_name])
|
||||
|
||||
# Run the VM asynchronously
|
||||
tart_run_process = tart.run_async(["run", vm_name])
|
||||
|
||||
# Obtain the VM's IP
|
||||
stdout, _ = tart.run(["ip", vm_name, "--wait", "120"])
|
||||
ip = stdout.strip()
|
||||
|
||||
# Connect to the VM over SSH and shutdown it
|
||||
client = SSHClient()
|
||||
client.set_missing_host_key_policy(AutoAddPolicy)
|
||||
client.connect(ip, username="admin", password="admin")
|
||||
client.exec_command("sudo shutdown -h now")
|
||||
|
||||
# Wait for the "tart run" to finish successfully
|
||||
tart_run_process.wait()
|
||||
assert tart_run_process.returncode == 0
|
||||
|
||||
# Delete the VM
|
||||
_, _ = tart.run(["delete", vm_name])
|
||||
Reference in New Issue
Block a user