mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-17 11:25:33 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eff964b62a | ||
|
|
590e064e35 | ||
|
|
839c6e7562 | ||
|
|
e3ee2da2fd | ||
|
|
84147f29b5 | ||
|
|
a655edd826 | ||
|
|
df100f1ca2 | ||
|
|
02bf5651e7 | ||
|
|
96c89ad76e | ||
|
|
b78fa6ba1c | ||
|
|
e443cfa9a2 |
@@ -31,6 +31,9 @@ struct Clone: AsyncParsableCommand {
|
|||||||
@Flag(help: .hidden)
|
@Flag(help: .hidden)
|
||||||
var deduplicate: Bool = false
|
var deduplicate: Bool = false
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("limit automatic pruning to n gigabytes", valueName: "n"))
|
||||||
|
var pruneLimit: UInt = 100
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
if newName.contains("/") {
|
if newName.contains("/") {
|
||||||
throw ValidationError("<new-name> should be a local name")
|
throw ValidationError("<new-name> should be a local name")
|
||||||
@@ -42,8 +45,8 @@ struct Clone: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
let ociStorage = VMStorageOCI()
|
let ociStorage = try VMStorageOCI()
|
||||||
let localStorage = VMStorageLocal()
|
let localStorage = try VMStorageLocal()
|
||||||
|
|
||||||
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
||||||
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
||||||
@@ -76,8 +79,10 @@ struct Clone: AsyncParsableCommand {
|
|||||||
//
|
//
|
||||||
// So, once we clone the VM let's try to claim the rest of space for the VM to run without errors.
|
// So, once we clone the VM let's try to claim the rest of space for the VM to run without errors.
|
||||||
let unallocatedBytes = try sourceVM.sizeBytes() - sourceVM.allocatedSizeBytes()
|
let unallocatedBytes = try sourceVM.sizeBytes() - sourceVM.allocatedSizeBytes()
|
||||||
if unallocatedBytes > 0 {
|
// Avoid reclaiming an excessive amount of disk space.
|
||||||
try Prune.reclaimIfNeeded(UInt64(unallocatedBytes), sourceVM)
|
let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024)
|
||||||
|
if reclaimBytes > 0 {
|
||||||
|
try Prune.reclaimIfNeeded(UInt64(reclaimBytes), sourceVM)
|
||||||
}
|
}
|
||||||
}, onCancel: {
|
}, onCancel: {
|
||||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||||
|
|||||||
@@ -87,11 +87,13 @@ struct Exec: AsyncParsableCommand {
|
|||||||
$0.args = Array(command.dropFirst(1))
|
$0.args = Array(command.dropFirst(1))
|
||||||
$0.interactive = interactive
|
$0.interactive = interactive
|
||||||
$0.tty = tty
|
$0.tty = tty
|
||||||
$0.terminalSize = .with {
|
if tty {
|
||||||
let (width, height) = try! Term.GetSize()
|
$0.terminalSize = .with {
|
||||||
|
let (width, height) = try! Term.GetSize()
|
||||||
|
|
||||||
$0.cols = UInt32(width)
|
$0.cols = UInt32(width)
|
||||||
$0.rows = UInt32(height)
|
$0.rows = UInt32(height)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ struct Import: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
let localStorage = VMStorageLocal()
|
let localStorage = try VMStorageLocal()
|
||||||
|
|
||||||
// Create a temporary VM directory to which we will load the export file
|
// Create a temporary VM directory to which we will load the export file
|
||||||
let tmpVMDir = try VMDirectory.temporary()
|
let tmpVMDir = try VMDirectory.temporary()
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ struct Login: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fileprivate class DictionaryCredentialsProvider: CredentialsProvider {
|
fileprivate class DictionaryCredentialsProvider: CredentialsProvider {
|
||||||
|
let userFriendlyName = "static dictionary credentials provider"
|
||||||
|
|
||||||
var credentials: Dictionary<String, (String, String)>
|
var credentials: Dictionary<String, (String, String)>
|
||||||
|
|
||||||
init(_ credentials: Dictionary<String, (String, String)>) {
|
init(_ credentials: Dictionary<String, (String, String)>) {
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ struct Prune: AsyncParsableCommand {
|
|||||||
|
|
||||||
switch entries {
|
switch entries {
|
||||||
case "caches":
|
case "caches":
|
||||||
prunableStorages = [VMStorageOCI(), try IPSWCache()]
|
prunableStorages = [try VMStorageOCI(), try IPSWCache()]
|
||||||
case "vms":
|
case "vms":
|
||||||
prunableStorages = [VMStorageLocal()]
|
prunableStorages = [try VMStorageLocal()]
|
||||||
default:
|
default:
|
||||||
throw ValidationError("unsupported --entries value, please specify either \"caches\" or \"vms\"")
|
throw ValidationError("unsupported --entries value, please specify either \"caches\" or \"vms\"")
|
||||||
}
|
}
|
||||||
@@ -152,7 +152,7 @@ struct Prune: AsyncParsableCommand {
|
|||||||
let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
|
let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
|
||||||
defer { transaction.finish() }
|
defer { transaction.finish() }
|
||||||
|
|
||||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
let prunableStorages: [PrunableStorage] = [try VMStorageOCI(), try IPSWCache()]
|
||||||
let prunables: [Prunable] = try prunableStorages
|
let prunables: [Prunable] = try prunableStorages
|
||||||
.flatMap { try $0.prunables() }
|
.flatMap { try $0.prunables() }
|
||||||
.sorted { try $0.accessDate() < $1.accessDate() }
|
.sorted { try $0.accessDate() < $1.accessDate() }
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ struct Pull: AsyncParsableCommand {
|
|||||||
func run() async throws {
|
func run() async throws {
|
||||||
// Be more liberal when accepting local image as argument,
|
// Be more liberal when accepting local image as argument,
|
||||||
// see https://github.com/cirruslabs/tart/issues/36
|
// see https://github.com/cirruslabs/tart/issues/36
|
||||||
if VMStorageLocal().exists(remoteName) {
|
if try VMStorageLocal().exists(remoteName) {
|
||||||
print("\"\(remoteName)\" is a local image, nothing to pull here!")
|
print("\"\(remoteName)\" is a local image, nothing to pull here!")
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ struct Push: AsyncParsableCommand {
|
|||||||
var populateCache: Bool = false
|
var populateCache: Bool = false
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
let ociStorage = VMStorageOCI()
|
let ociStorage = try VMStorageOCI()
|
||||||
let localVMDir = try VMStorageHelper.open(localName)
|
let localVMDir = try VMStorageHelper.open(localName)
|
||||||
let lock = try localVMDir.lock()
|
let lock = try localVMDir.lock()
|
||||||
if try !lock.trylock() {
|
if try !lock.trylock() {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ struct Rename: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
let localStorage = VMStorageLocal()
|
let localStorage = try VMStorageLocal()
|
||||||
|
|
||||||
if !localStorage.exists(name) {
|
if !localStorage.exists(name) {
|
||||||
throw ValidationError("failed to rename a non-existent local VM: \(name)")
|
throw ValidationError("failed to rename a non-existent local VM: \(name)")
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ struct Run: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let localStorage = VMStorageLocal()
|
let localStorage = try VMStorageLocal()
|
||||||
let vmDir = try localStorage.open(name)
|
let vmDir = try localStorage.open(name)
|
||||||
if try vmDir.state() == .Suspended {
|
if try vmDir.state() == .Suspended {
|
||||||
suspendable = true
|
suspendable = true
|
||||||
@@ -334,7 +334,7 @@ struct Run: AsyncParsableCommand {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
let localStorage = VMStorageLocal()
|
let localStorage = try VMStorageLocal()
|
||||||
let vmDir = try localStorage.open(name)
|
let vmDir = try localStorage.open(name)
|
||||||
|
|
||||||
// Validate disk format support
|
// Validate disk format support
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ struct Config {
|
|||||||
var tartHomeDir: URL
|
var tartHomeDir: URL
|
||||||
|
|
||||||
if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] {
|
if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] {
|
||||||
tartHomeDir = URL(fileURLWithPath: customTartHome)
|
tartHomeDir = URL(fileURLWithPath: customTartHome, isDirectory: true)
|
||||||
|
try Self.validateTartHome(url: tartHomeDir)
|
||||||
} else {
|
} else {
|
||||||
tartHomeDir = FileManager.default
|
tartHomeDir = FileManager.default
|
||||||
.homeDirectoryForCurrentUser
|
.homeDirectoryForCurrentUser
|
||||||
@@ -49,4 +50,24 @@ struct Config {
|
|||||||
static func jsonDecoder() -> JSONDecoder {
|
static func jsonDecoder() -> JSONDecoder {
|
||||||
JSONDecoder()
|
JSONDecoder()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func validateTartHome(url: URL) throws {
|
||||||
|
let urlComponents = url.pathComponents
|
||||||
|
|
||||||
|
let descendingURLs = urlComponents.indices.map { i in
|
||||||
|
URL(fileURLWithPath: urlComponents[0...i].joined(separator: "/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
for descendingURL in descendingURLs {
|
||||||
|
if FileManager.default.fileExists(atPath: descendingURL.path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: descendingURL, withIntermediateDirectories: false)
|
||||||
|
} catch {
|
||||||
|
throw RuntimeError.Generic("TART_HOME is invalid: \(descendingURL.path) does not exist, yet we can't create it: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ enum CredentialsProviderError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protocol CredentialsProvider {
|
protocol CredentialsProvider {
|
||||||
|
var userFriendlyName: String { get }
|
||||||
func retrieve(host: String) throws -> (String, String)?
|
func retrieve(host: String) throws -> (String, String)?
|
||||||
func store(host: String, user: String, password: String) throws
|
func store(host: String, user: String, password: String) throws
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class DockerConfigCredentialsProvider: CredentialsProvider {
|
class DockerConfigCredentialsProvider: CredentialsProvider {
|
||||||
|
let userFriendlyName = "Docker configuration credentials provider"
|
||||||
|
|
||||||
func retrieve(host: String) throws -> (String, String)? {
|
func retrieve(host: String) throws -> (String, String)? {
|
||||||
let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json")
|
let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json")
|
||||||
if !FileManager.default.fileExists(atPath: dockerConfigURL.path) {
|
if !FileManager.default.fileExists(atPath: dockerConfigURL.path) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class EnvironmentCredentialsProvider: CredentialsProvider {
|
class EnvironmentCredentialsProvider: CredentialsProvider {
|
||||||
|
let userFriendlyName = "environment variable credentials provider"
|
||||||
|
|
||||||
func retrieve(host: String) throws -> (String, String)? {
|
func retrieve(host: String) throws -> (String, String)? {
|
||||||
if let tartRegistryHostname = ProcessInfo.processInfo.environment["TART_REGISTRY_HOSTNAME"],
|
if let tartRegistryHostname = ProcessInfo.processInfo.environment["TART_REGISTRY_HOSTNAME"],
|
||||||
tartRegistryHostname != host {
|
tartRegistryHostname != host {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class KeychainCredentialsProvider: CredentialsProvider {
|
class KeychainCredentialsProvider: CredentialsProvider {
|
||||||
|
let userFriendlyName = "Keychain credentials provider"
|
||||||
|
|
||||||
func retrieve(host: String) throws -> (String, String)? {
|
func retrieve(host: String) throws -> (String, String)? {
|
||||||
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
||||||
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
|
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ enum StdinCredentialsError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class StdinCredentials {
|
class StdinCredentials {
|
||||||
|
let userFriendlyName = "standard input credentials provider"
|
||||||
|
|
||||||
static func retrieve() throws -> (String, String) {
|
static func retrieve() throws -> (String, String) {
|
||||||
let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false)
|
let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false)
|
||||||
let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true)
|
let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ enum DiskImageFormat: String, CaseIterable, Codable {
|
|||||||
case .raw:
|
case .raw:
|
||||||
return true
|
return true
|
||||||
case .asif:
|
case .asif:
|
||||||
if #available(macOS 15, *) {
|
if #available(macOS 26, *) {
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -4,18 +4,28 @@ public class ProgressObserver: NSObject {
|
|||||||
@objc var progressToObserve: Progress
|
@objc var progressToObserve: Progress
|
||||||
var observation: NSKeyValueObservation?
|
var observation: NSKeyValueObservation?
|
||||||
var lastTimeUpdated = Date.now
|
var lastTimeUpdated = Date.now
|
||||||
|
private var lastRenderedLine: String?
|
||||||
|
|
||||||
public init(_ progress: Progress) {
|
public init(_ progress: Progress) {
|
||||||
progressToObserve = progress
|
progressToObserve = progress
|
||||||
}
|
}
|
||||||
|
|
||||||
func log(_ renderer: Logger) {
|
func log(_ renderer: Logger) {
|
||||||
renderer.appendNewLine(ProgressObserver.lineToRender(progressToObserve))
|
let initialLine = ProgressObserver.lineToRender(progressToObserve)
|
||||||
|
renderer.appendNewLine(initialLine)
|
||||||
|
lastRenderedLine = initialLine
|
||||||
observation = observe(\.progressToObserve.fractionCompleted) { progress, _ in
|
observation = observe(\.progressToObserve.fractionCompleted) { progress, _ in
|
||||||
let currentTime = Date.now
|
let currentTime = Date.now
|
||||||
if self.progressToObserve.isFinished || currentTime.timeIntervalSince(self.lastTimeUpdated) >= 1.0 {
|
if self.progressToObserve.isFinished || currentTime.timeIntervalSince(self.lastTimeUpdated) >= 1.0 {
|
||||||
self.lastTimeUpdated = currentTime
|
self.lastTimeUpdated = currentTime
|
||||||
renderer.updateLastLine(ProgressObserver.lineToRender(self.progressToObserve))
|
let line = ProgressObserver.lineToRender(self.progressToObserve)
|
||||||
|
// Skip identical renders so non-interactive logs only see new percent values.
|
||||||
|
if line == self.lastRenderedLine {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.lastRenderedLine = line
|
||||||
|
renderer.updateLastLine(line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -429,8 +429,12 @@ class Registry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for provider in credentialsProviders {
|
for provider in credentialsProviders {
|
||||||
if let (user, password) = try provider.retrieve(host: host) {
|
do {
|
||||||
return (user, password)
|
if let (user, password) = try provider.retrieve(host: host) {
|
||||||
|
return (user, password)
|
||||||
|
}
|
||||||
|
} catch (let e) {
|
||||||
|
print("Failed to retrieve credentials using \(provider.userFriendlyName), authentication may fail: \(e)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ struct Root: AsyncParsableCommand {
|
|||||||
do {
|
do {
|
||||||
try Config().gc()
|
try Config().gc()
|
||||||
} catch {
|
} catch {
|
||||||
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
|
fputs("Failed to perform garbage collection: \(error)\n", stderr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class VMStorageLocal: PrunableStorage {
|
class VMStorageLocal: PrunableStorage {
|
||||||
let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
let baseURL: URL
|
||||||
|
|
||||||
|
init() throws {
|
||||||
|
baseURL = try Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
private func vmURL(_ name: String) -> URL {
|
private func vmURL(_ name: String) -> URL {
|
||||||
baseURL.appendingPathComponent(name, isDirectory: true)
|
baseURL.appendingPathComponent(name, isDirectory: true)
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import Sentry
|
|||||||
import Retry
|
import Retry
|
||||||
|
|
||||||
class VMStorageOCI: PrunableStorage {
|
class VMStorageOCI: PrunableStorage {
|
||||||
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
let baseURL: URL
|
||||||
|
|
||||||
|
init() throws {
|
||||||
|
baseURL = try Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
private func vmURL(_ name: RemoteName) -> URL {
|
private func vmURL(_ name: RemoteName) -> URL {
|
||||||
baseURL.appendingRemoteName(name)
|
baseURL.appendingRemoteName(name)
|
||||||
|
|||||||
@@ -2,13 +2,9 @@ import XCTest
|
|||||||
@testable import tart
|
@testable import tart
|
||||||
|
|
||||||
final class DiskImageFormatTests: XCTestCase {
|
final class DiskImageFormatTests: XCTestCase {
|
||||||
func testRawFormatIsAlwaysSupported() throws {
|
|
||||||
XCTAssertTrue(DiskImageFormat.raw.isSupported)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testASIFFormatSupport() throws {
|
func testASIFFormatSupport() throws {
|
||||||
// ASIF should be supported on macOS 15+
|
// ASIF should be supported on macOS 26+
|
||||||
if #available(macOS 15, *) {
|
if #available(macOS 26, *) {
|
||||||
XCTAssertTrue(DiskImageFormat.asif.isSupported)
|
XCTAssertTrue(DiskImageFormat.asif.isSupported)
|
||||||
} else {
|
} else {
|
||||||
XCTAssertFalse(DiskImageFormat.asif.isSupported)
|
XCTAssertFalse(DiskImageFormat.asif.isSupported)
|
||||||
|
|||||||
@@ -11,3 +11,4 @@
|
|||||||
"MD045": false # OK not to have a description for an image
|
"MD045": false # OK not to have a description for an image
|
||||||
"MD046": false # Code block style [Expected: fenced; Actual: indented]
|
"MD046": false # Code block style [Expected: fenced; Actual: indented]
|
||||||
"MD059": false # It's OK to have "here" links
|
"MD059": false # It's OK to have "here" links
|
||||||
|
"MD051": false # MkDocs generates "#-no-pki" anchors, but markdownlint expects "#--no-pki" anchors
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ device without a physical display connected. For example, a Mac Mini with a HDMI
|
|||||||
but a Mac Mini on a desk with a connected physical display is considered a personal computer. **Usage on personal computers
|
but a Mac Mini on a desk with a connected physical display is considered a personal computer. **Usage on personal computers
|
||||||
and before reaching the 100 CPU cores limit is royalty-free and does not have the viral properties of AGPL.**
|
and before reaching the 100 CPU cores limit is royalty-free and does not have the viral properties of AGPL.**
|
||||||
|
|
||||||
When an organization surpasses the 100 CPU cores limit, they will be required to obtain a [Gold Tier License](/licensing#license-tiers),
|
When an organization surpasses the 100 CPU cores limit, they will be required to obtain a [Gold Tier License](../../licensing.md#license-tiers),
|
||||||
which costs \$1000 per month. Upon reaching a limit of 500 CPU cores, a [Platinum Tier License](/licensing#license-tiers)
|
which costs \$1000 per month. Upon reaching a limit of 500 CPU cores, a [Platinum Tier License](../../licensing.md#license-tiers)
|
||||||
(\$3000 per month) will be required, and for organizations that exceed 3000 CPU cores, a custom [Diamond Tier License](/licensing#license-tiers)
|
(\$3000 per month) will be required, and for organizations that exceed 3000 CPU cores, a custom [Diamond Tier License](../../licensing.md#license-tiers)
|
||||||
(\$1 per core per month) will be necessary. **All paid license tiers will include priority feature development and SLAs on support with urgent issues.**
|
(\$1 per core per month) will be necessary. **All paid license tiers will include priority feature development and SLAs on support with urgent issues.**
|
||||||
|
|
||||||
## Have we considered alternatives?
|
## Have we considered alternatives?
|
||||||
|
|||||||
@@ -89,6 +89,6 @@ orchard dev
|
|||||||
This will launch a development cluster with a single worker on your machine. Refer to [Orchard documentation](https://github.com/cirruslabs/orchard#creating-virtual-machines)
|
This will launch a development cluster with a single worker on your machine. Refer to [Orchard documentation](https://github.com/cirruslabs/orchard#creating-virtual-machines)
|
||||||
on how to create your first virtual machine and access it.
|
on how to create your first virtual machine and access it.
|
||||||
|
|
||||||
In a [separate blog post](/blog/2023/04/28/ssh-over-grpc-or-how-orchard-simplifies-accessing-vms-in-private-networks/)
|
In a [separate blog post](2023-04-28-orchard-ssh-over-grpc.md)
|
||||||
we’ll cover how Orchard implements seamless SSH access over a gRPC connection. Stay tuned and please don’t hesitate to
|
we’ll cover how Orchard implements seamless SSH access over a gRPC connection. Stay tuned and please don’t hesitate to
|
||||||
[reach out](https://github.com/cirruslabs/orchard/discussions/landing)!
|
[reach out](https://github.com/cirruslabs/orchard/discussions/landing)!
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ We’ve also initially considered using [Yamux](https://github.com/hashicorp/yam
|
|||||||
|
|
||||||
First of all, we’ve made the new port-forwarding functionality available for integrations via the Orchard’s REST API:
|
First of all, we’ve made the new port-forwarding functionality available for integrations via the Orchard’s REST API:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
All you need is to use a WebSocket client when accessing this endpoint to make it work.
|
All you need is to use a WebSocket client when accessing this endpoint to make it work.
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ allocate time to continue improving Tart which brings us to the section below.
|
|||||||
In the last 7 months we've had 12 feature releases that brought a lot of features requested by the community. Here are just
|
In the last 7 months we've had 12 feature releases that brought a lot of features requested by the community. Here are just
|
||||||
a few of them to highlight:
|
a few of them to highlight:
|
||||||
|
|
||||||
-[Custom GitLab Runner Executor](/integrations/gitlab-runner/).
|
-[Custom GitLab Runner Executor](../../integrations/gitlab-runner.md).
|
||||||
-[Cluster Management via Orchard](2023-04-25-orchard-ga.md).
|
-[Cluster Management via Orchard](2023-04-25-orchard-ga.md).
|
||||||
-Numerous compatibility improvements for all kinds of OCI-registries.
|
-Numerous compatibility improvements for all kinds of OCI-registries.
|
||||||
-Sonoma Support (see details [below](#macos-sonoma-updates)).
|
-Sonoma Support (see details [below](#macos-sonoma-updates)).
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ with preconfigured Tart installation that is optimized to work within AWS infras
|
|||||||
EC2 Mac Instances is a gem of engineering powered by AWS Nitro devices. Just imagine there is a physical Mac Mini with
|
EC2 Mac Instances is a gem of engineering powered by AWS Nitro devices. Just imagine there is a physical Mac Mini with
|
||||||
a plugged in Nitro device that can push the physical power button!
|
a plugged in Nitro device that can push the physical power button!
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
This clever synergy between Apple Hardware and Nitro System allows seamless integration with VPC networking and booting macOS from an EBS volume.
|
This clever synergy between Apple Hardware and Nitro System allows seamless integration with VPC networking and booting macOS from an EBS volume.
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ than recently announced Apple Silicon GitHub-manged runners that cost $0.16 per
|
|||||||
Now lets take a look at the new Cirrus Runners dashboard of a real customers that run their workflows on Cirrus Runners
|
Now lets take a look at the new Cirrus Runners dashboard of a real customers that run their workflows on Cirrus Runners
|
||||||
and **practically pushing the price performance pretty close to the theoretical minimum**.
|
and **practically pushing the price performance pretty close to the theoretical minimum**.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
As you can see above Cirrus Runners Dashboard focuses on 4 core metrics:
|
As you can see above Cirrus Runners Dashboard focuses on 4 core metrics:
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ we can see that the downside of such great price performance is that jobs are wa
|
|||||||
Here is another example of Cirrus Runners Dashboard for a different customer that has a slightly higher price performance of $0.017 per minute
|
Here is another example of Cirrus Runners Dashboard for a different customer that has a slightly higher price performance of $0.017 per minute
|
||||||
but at the same time doesn't experience queue time at all. **Note that $0.017 is still 10 times cheaper than GitHub-managed Apple Silicon runners**.
|
but at the same time doesn't experience queue time at all. **Note that $0.017 is still 10 times cheaper than GitHub-managed Apple Silicon runners**.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Conclusion
|
## Conclusion
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -34,7 +34,7 @@ Then from within a virtual machine you can access the service using the router's
|
|||||||
or by running the following command in the Terminal:
|
or by running the following command in the Terminal:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
netstat -nr | grep default | head -n 1 | awk '{print $2}'
|
netstat -nr | awk '/default/{print $2; exit}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: that accessing host is only possible with the default NAT network. If you are running your virtual machines with
|
Note: that accessing host is only possible with the default NAT network. If you are running your virtual machines with
|
||||||
@@ -150,7 +150,7 @@ This command also supports the `-p` command-line argument that allows you to sup
|
|||||||
|
|
||||||
If that doesn't work for you for some reason, you can pass the credentials via the environment variables, see [Registry Authorization](integrations/vm-management.md#registry-authorization) for more details on how to do that.
|
If that doesn't work for you for some reason, you can pass the credentials via the environment variables, see [Registry Authorization](integrations/vm-management.md#registry-authorization) for more details on how to do that.
|
||||||
|
|
||||||
## How Tart is different from Anka?
|
## How is Tart different from Anka?
|
||||||
|
|
||||||
Under the hood Tart is using the same technology as Anka 3.0 so there should be no real difference in performance
|
Under the hood Tart is using the same technology as Anka 3.0 so there should be no real difference in performance
|
||||||
or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/cirruslabs/tart/issues).
|
or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/cirruslabs/tart/issues).
|
||||||
@@ -166,6 +166,8 @@ Tart does have an analogue of Anka Controller for managing VMs across a cluster
|
|||||||
|
|
||||||
In case there's not enough space to fit the newly pulled or cloned VM image, Tart will remove the least recently accessed VMs from OCI cache and `.ipsw` files from IPSW cache until enough free space is available.
|
In case there's not enough space to fit the newly pulled or cloned VM image, Tart will remove the least recently accessed VMs from OCI cache and `.ipsw` files from IPSW cache until enough free space is available.
|
||||||
|
|
||||||
|
The `tart clone` command limits this automatic pruning to 100 GB by default to avoid removing too many cached items. You can change this limit with the `--prune-limit` option (in gigabytes).
|
||||||
|
|
||||||
To disable this functionality, set the `TART_NO_AUTO_PRUNE` environment variable either globally:
|
To disable this functionality, set the `TART_NO_AUTO_PRUNE` environment variable either globally:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ description: Run pipeline steps in isolated ephemeral Tart Virtual Machines.
|
|||||||
|
|
||||||
It is possible to run [Buildkite](https://buildkite.com/) pipeline steps in isolated ephemeral Tart Virtual Machines with the help of [Tart Buildkite Plugin](https://github.com/cirruslabs/tart-buildkite-plugin):
|
It is possible to run [Buildkite](https://buildkite.com/) pipeline steps in isolated ephemeral Tart Virtual Machines with the help of [Tart Buildkite Plugin](https://github.com/cirruslabs/tart-buildkite-plugin):
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ brew install cirruslabs/cli/cirrus
|
|||||||
cirrus run
|
cirrus run
|
||||||
```
|
```
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
[Cirrus CI](https://cirrus-ci.org/) already leverages Tart to power its macOS cloud infrastructure. The `.cirrus.yml`
|
[Cirrus CI](https://cirrus-ci.org/) already leverages Tart to power its macOS cloud infrastructure. The `.cirrus.yml`
|
||||||
config from above will just work in Cirrus CI and your tasks will be executed inside Tart VMs in our cloud.
|
config from above will just work in Cirrus CI and your tasks will be executed inside Tart VMs in our cloud.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Orchard cluster consists of three components:
|
|||||||
|
|
||||||
* Controller — responsible for managing the cluster and scheduling of resources
|
* Controller — responsible for managing the cluster and scheduling of resources
|
||||||
* Worker — responsible for executing the VMs
|
* Worker — responsible for executing the VMs
|
||||||
* Client — responsible for creating, modifying and removing the resources on the Controller, can either be an [Orchard CLI](/orchard/using-orchard-cli) or [an API consumer](/orchard/integration-guide)
|
* Client — responsible for creating, modifying and removing the resources on the Controller, can either be an [Orchard CLI](using-orchard-cli.md) or [an API consumer](integration-guide.md)
|
||||||
|
|
||||||
At the moment, only one Controller instance is currently supported, while you can deploy one or more Workers and run any number of Clients.
|
At the moment, only one Controller instance is currently supported, while you can deploy one or more Workers and run any number of Clients.
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ In terms of networking requirements, only Controller needs to be directly access
|
|||||||
|
|
||||||
When an Orchard Client or a Worker connects to the Controller, they need to establish trust and verify that they're talking to the right Controller, so that no [man-in-the-middle attack](https://en.wikipedia.org/wiki/Man-in-the-middle_attack) is possible.
|
When an Orchard Client or a Worker connects to the Controller, they need to establish trust and verify that they're talking to the right Controller, so that no [man-in-the-middle attack](https://en.wikipedia.org/wiki/Man-in-the-middle_attack) is possible.
|
||||||
|
|
||||||
Similarly to web-browsers (that rely on the [public key infrastructure](https://en.wikipedia.org/wiki/Public_key_infrastructure)) and SSH (which relies on semi-automated fingerprint verification), Orchard combines these two traits in a hybrid approach by defaulting to automatic PKI verification (can be disabled by [`--no-pki`](#--no-pki-override)) and falling-back to a manual verification for self-signed certificates.
|
Similarly to web-browsers (that rely on the [public key infrastructure](https://en.wikipedia.org/wiki/Public_key_infrastructure)) and SSH (which relies on semi-automated fingerprint verification), Orchard combines these two traits in a hybrid approach by defaulting to automatic PKI verification (can be disabled by [`--no-pki`](#-no-pki-override)) and falling-back to a manual verification for self-signed certificates.
|
||||||
|
|
||||||
This hybrid approach is needed because the Controller can be configured in two ways:
|
This hybrid approach is needed because the Controller can be configured in two ways:
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ Below we'll explain how Orchard client and Worker secure the connection when acc
|
|||||||
|
|
||||||
Client is associated with the Controller using a `orchard context create` command, which works as follows:
|
Client is associated with the Controller using a `orchard context create` command, which works as follows:
|
||||||
|
|
||||||
* Client attempts to connect to the Controller and validate its certificate using host's root CA set (can be disabled with [`--no-pki`](#--no-pki-override))
|
* Client attempts to connect to the Controller and validate its certificate using host's root CA set (can be disabled with [`--no-pki`](#-no-pki-override))
|
||||||
* if the Client encounters a *Controller with a publicly valid certificate*, that would be the last step and the association would succeed
|
* if the Client encounters a *Controller with a publicly valid certificate*, that would be the last step and the association would succeed
|
||||||
* if the Client is dealing with *Controller with a self-signed certificate*, the Client will do another connection attempt to probe the Controller's certificate
|
* if the Client is dealing with *Controller with a self-signed certificate*, the Client will do another connection attempt to probe the Controller's certificate
|
||||||
* the probed Controller's certificate fingerprint is then presented to the user, and if the user agrees to trust it, the Client then considers that certificate to be trusted for a given context
|
* the probed Controller's certificate fingerprint is then presented to the user, and if the user agrees to trust it, the Client then considers that certificate to be trusted for a given context
|
||||||
@@ -53,7 +53,7 @@ The way Worker connects to the Controller using the `orchard worker run` command
|
|||||||
* when the Bootstrap Token contains the Controller's certificate:
|
* when the Bootstrap Token contains the Controller's certificate:
|
||||||
* the Orchard Worker will try to connect to the Controller with a trusted CA set containing only that certificate
|
* the Orchard Worker will try to connect to the Controller with a trusted CA set containing only that certificate
|
||||||
* when the Bootstrap Token has no Controller's certificate:
|
* when the Bootstrap Token has no Controller's certificate:
|
||||||
* the Orchard Worker will try the PKI approach (can be disabled with [`--no-pki`](#--no-pki-override) to effectively prevent the Worker from connecting) and fail if certificate verification using PKI is not possible
|
* the Orchard Worker will try the PKI approach (can be disabled with [`--no-pki`](#-no-pki-override) to effectively prevent the Worker from connecting) and fail if certificate verification using PKI is not possible
|
||||||
|
|
||||||
### `--no-pki` override
|
### `--no-pki` override
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ Here's other command-line arguments associated with this functionality:
|
|||||||
* `--insecure-ssh-no-client-auth` — allow SSH clients to connect to the controller's SSH server without authentication, thus only authenticating on the target worker/VM's SSH server
|
* `--insecure-ssh-no-client-auth` — allow SSH clients to connect to the controller's SSH server without authentication, thus only authenticating on the target worker/VM's SSH server
|
||||||
* useful when you already have strong credentials on your VMs, and you want to share these VMs to others without additionally giving out Orchard Cluster credentials
|
* useful when you already have strong credentials on your VMs, and you want to share these VMs to others without additionally giving out Orchard Cluster credentials
|
||||||
|
|
||||||
Check out our [Jumping through the hoops: SSH jump host functionality in Orchard](/blog/2024/06/20/jumping-through-the-hoops-ssh-jump-host-functionality-in-orchard/) blog post for more information.
|
Check out our [Jumping through the hoops: SSH jump host functionality in Orchard](../blog/posts/2024-06-20-jumping-through-the-hoops.md) blog post for more information.
|
||||||
|
|
||||||
## Deployment Methods
|
## Deployment Methods
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ Orchard has a REST API that follows [OpenAPI specification](https://swagger.io/s
|
|||||||
|
|
||||||
You can run `orchard dev` locally and navigate to `http://127.0.0.1:6120/v1/` for interactive documentation.
|
You can run `orchard dev` locally and navigate to `http://127.0.0.1:6120/v1/` for interactive documentation.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Using the API
|
## Using the API
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ a couple of VMs is not enough anymore for your needs? This is where [Orchard](ht
|
|||||||
comes in to play!
|
comes in to play!
|
||||||
|
|
||||||
It allows you to orchestrate multiple Tart-capable hosts from either an Orchard CLI (which we demonstrate below)
|
It allows you to orchestrate multiple Tart-capable hosts from either an Orchard CLI (which we demonstrate below)
|
||||||
or [through the API](/orchard/integration-guide).
|
or [through the API](integration-guide.md).
|
||||||
|
|
||||||
The easiest way to start is to run Orchard in local development mode:
|
The easiest way to start is to run Orchard in local development mode:
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ test both the CLI functionality and the API from a tool like cURL or programming
|
|||||||
authenticate requests.
|
authenticate requests.
|
||||||
|
|
||||||
Note that in production deployments, these two components are started separately and enable security by default. Please
|
Note that in production deployments, these two components are started separately and enable security by default. Please
|
||||||
refer to [Deploying Controller](/orchard/deploying-controller) and [Deploying Workers](/orchard/deploying-workers) for
|
refer to [Deploying Controller](deploying-controller.md) and [Deploying Workers](deploying-workers.md) for
|
||||||
more information.
|
more information.
|
||||||
|
|
||||||
## Creating Virtual Machines
|
## Creating Virtual Machines
|
||||||
@@ -92,10 +92,10 @@ orchard delete vm sequoia-base
|
|||||||
In addition to controlling the Orchard via the CLI arguments, there are environment variables that may be beneficial
|
In addition to controlling the Orchard via the CLI arguments, there are environment variables that may be beneficial
|
||||||
both when automating Orchard and in daily use:
|
both when automating Orchard and in daily use:
|
||||||
|
|
||||||
| Variable name | Description |
|
| Variable name | Description |
|
||||||
|---------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|---------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `ORCHARD_HOME` | Override Orchard's home directory. Useful when running multiple Orchard instances on the same host and when testing. |
|
| `ORCHARD_HOME` | Override Orchard's home directory. Useful when running multiple Orchard instances on the same host and when testing. |
|
||||||
| `ORCHARD_LICENSE_TIER` | The default license limit only allows connecting 4 Orchard Workers to the Orchard Controller. If you've purchased a [Gold Tier License](/licensing/), set this variable to `gold` to increase the limit to 20 Orchard Workers. And if you've purchased a [Platinum Tier License](/licensing/), set this variable to `platinum` to increase the limit to 200 Orchard Workers. |
|
| `ORCHARD_LICENSE_TIER` | The default license limit only allows connecting 4 Orchard Workers to the Orchard Controller. If you've purchased a [Gold Tier License](../licensing.md), set this variable to `gold` to increase the limit to 20 Orchard Workers. And if you've purchased a [Platinum Tier License](../licensing.md), set this variable to `platinum` to increase the limit to 200 Orchard Workers. |
|
||||||
| `ORCHARD_URL` | Override controller URL on per-command basis. |
|
| `ORCHARD_URL` | Override controller URL on per-command basis. |
|
||||||
| `ORCHARD_SERVICE_ACCOUNT_NAME` | Override service account name (used for controller API auth) on per-command basis. |
|
| `ORCHARD_SERVICE_ACCOUNT_NAME` | Override service account name (used for controller API auth) on per-command basis. |
|
||||||
| `ORCHARD_SERVICE_ACCOUNT_TOKEN` | Override service account token (used for controller API auth) on per-command basis. |
|
| `ORCHARD_SERVICE_ACCOUNT_TOKEN` | Override service account token (used for controller API auth) on per-command basis. |
|
||||||
|
|||||||
@@ -75,3 +75,12 @@ orchard create vm --resources bandwidth-mbps=7500 <NAME>
|
|||||||
However, after this VM is scheduled, the 10 Gbps Mac Studio will only be able to accommodate one more VM (due to internal Apple EULA limit for macOS virtualization) with `bandwidth-mbps=2500` or less.
|
However, after this VM is scheduled, the 10 Gbps Mac Studio will only be able to accommodate one more VM (due to internal Apple EULA limit for macOS virtualization) with `bandwidth-mbps=2500` or less.
|
||||||
|
|
||||||
After the VM finishes, the unused resources will be available again.
|
After the VM finishes, the unused resources will be available again.
|
||||||
|
|
||||||
|
## Automatic resources
|
||||||
|
|
||||||
|
In addition to manually specifying resources when starting a worker, the following resources are discovered and set automatically by the worker for convenience:
|
||||||
|
|
||||||
|
* `org.cirruslabs.logical-cores` — number of logical cores on the host
|
||||||
|
* `org.cirruslabs.memory-mib` — total memory in MiB (mebibytes) on the host
|
||||||
|
|
||||||
|
Note that the values for these resources are scraped only once at worker startup.
|
||||||
|
|||||||
+2
-2
@@ -95,8 +95,8 @@ ssh admin@$(tart ip sequoia-base)
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install cirruslabs/cli/sshpass
|
brew install cirruslabs/cli/sshpass
|
||||||
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip sequoia-base) "uname -a"
|
sshpass -p admin ssh -o "StrictHostKeyChecking no" -o "UserKnownHostsFile=/dev/null" admin@$(tart ip sequoia-base) "uname -a"
|
||||||
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip sequoia-base) < script.sh
|
sshpass -p admin ssh -o "StrictHostKeyChecking no" -o "UserKnownHostsFile=/dev/null" admin@$(tart ip sequoia-base) < script.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Mounting directories
|
## Mounting directories
|
||||||
|
|||||||
Reference in New Issue
Block a user