mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-01 19:51:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63a2793c32 | ||
|
|
0fc3d3d1f4 | ||
|
|
60b705478b | ||
|
|
fa9e3146c1 | ||
|
|
3dfe8f870c | ||
|
|
7afa446a73 | ||
|
|
d1bed25023 | ||
|
|
b39c3c9b52 | ||
|
|
8554e56e4b | ||
|
|
3fdcc5c33c | ||
|
|
182ddf0268 |
@@ -35,3 +35,5 @@ brews:
|
||||
homepage: https://github.com/cirruslabs/tart
|
||||
description: Run macOS VMs on Apple Silicon
|
||||
skip_upload: auto
|
||||
custom_block: |
|
||||
depends_on :macos => :monterey
|
||||
|
||||
@@ -8,7 +8,7 @@ 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.
|
||||
|
||||
Try running a Tart VM on your Apple Silicon device (will download a 25 GB image):
|
||||
Try running a Tart VM on your Apple Silicon device running macOS Monterey or later (will download a 25 GB image):
|
||||
|
||||
```shell
|
||||
brew install cirruslabs/cli/tart
|
||||
@@ -20,9 +20,13 @@ tart run monterey-base
|
||||
|
||||
## CI Integration
|
||||
|
||||
[Cirrus CLI](https://github.com/cirruslabs/cirrus-cli) is an open-sourced CI-agnostic tool that can run workloads
|
||||
inside containers via Docker or Podman and now inside macOS VMs via Tart. Put the following `.cirrus.yml` file
|
||||
in the root of your repository:
|
||||
Tart itself is only responsible for managing virtual machines, but we've built Tart support into a tool called Cirrus CLI
|
||||
also developed by Cirrus Labs. [Cirrus CLI](https://github.com/cirruslabs/cirrus-cli) is a command line tool with
|
||||
one configuration format to execute common CI steps (run a script, cache a folder, etc.) locally or in any CI system.
|
||||
We built Cirrus CLI to solve "But it works on my machine!" problem.
|
||||
|
||||
Here is an example of a `.cirrus.yml` configuration file which will start a Tart VM, will copy over working directory and
|
||||
will run scripts and [other instructions](https://cirrus-ci.org/guide/writing-tasks/#supported-instructions) inside the virtual machine:
|
||||
|
||||
```yaml
|
||||
task:
|
||||
@@ -37,7 +41,7 @@ task:
|
||||
- sleep 15
|
||||
```
|
||||
|
||||
Run it locally or in CI with the following command:
|
||||
Put the above `.cirrus.yml` file in the root of your repository and run it with the following command:
|
||||
|
||||
```shell
|
||||
brew install cirruslabs/cli/cirrus
|
||||
@@ -78,7 +82,7 @@ Please refer to `tart set --help` for additional details.
|
||||
|
||||
### Building with Packer
|
||||
|
||||
Please refer to [Tart Packer Plugin reposiotry](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
|
||||
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:
|
||||
|
||||
```json
|
||||
|
||||
@@ -13,7 +13,7 @@ struct Create: AsyncParsableCommand {
|
||||
var fromIPSW: String?
|
||||
|
||||
@Option(help: ArgumentHelp("Disk size in Gb"))
|
||||
var diskSize: UInt8 = 32
|
||||
var diskSize: UInt8 = 50
|
||||
|
||||
func validate() throws {
|
||||
if fromIPSW == nil {
|
||||
|
||||
@@ -10,6 +10,14 @@ struct Pull: AsyncParsableCommand {
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
// Be more liberal when accepting local image as argument,
|
||||
// see https://github.com/cirruslabs/tart/issues/36
|
||||
if VMStorageLocal().exists(remoteName) {
|
||||
print("\"\(remoteName)\" is a local image, nothing to pull here!")
|
||||
|
||||
Foundation.exit(0)
|
||||
}
|
||||
|
||||
let remoteName = try RemoteName(remoteName)
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace)
|
||||
|
||||
|
||||
@@ -18,51 +18,56 @@ struct Run: AsyncParsableCommand {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
vm = try VM(vmDir: vmDir)
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await vm!.run()
|
||||
await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
do {
|
||||
try await vm!.run()
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if noGraphics {
|
||||
dispatchMain()
|
||||
} else {
|
||||
// UI mumbo-jumbo
|
||||
let nsApp = NSApplication.shared
|
||||
nsApp.setActivationPolicy(.regular)
|
||||
nsApp.activate(ignoringOtherApps: true)
|
||||
|
||||
nsApp.applicationIconImage = NSImage(data: AppIconData)
|
||||
|
||||
struct MainApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup(vm!.name) {
|
||||
Group {
|
||||
VMView(vm: vm!).onAppear {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
}
|
||||
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
|
||||
}.commands {
|
||||
// Remove some standard menu options
|
||||
CommandGroup(replacing: .help, addition: {})
|
||||
CommandGroup(replacing: .newItem, addition: {})
|
||||
CommandGroup(replacing: .pasteboard, addition: {})
|
||||
CommandGroup(replacing: .textEditing, addition: {})
|
||||
CommandGroup(replacing: .undoRedo, addition: {})
|
||||
CommandGroup(replacing: .windowSize, addition: {})
|
||||
}
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
MainApp.main()
|
||||
if noGraphics {
|
||||
dispatchMain()
|
||||
} else {
|
||||
runUI()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runUI() {
|
||||
let nsApp = NSApplication.shared
|
||||
nsApp.setActivationPolicy(.regular)
|
||||
nsApp.activate(ignoringOtherApps: true)
|
||||
|
||||
nsApp.applicationIconImage = NSImage(data: AppIconData)
|
||||
|
||||
struct MainApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup(vm!.name) {
|
||||
Group {
|
||||
VMView(vm: vm!).onAppear {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
}
|
||||
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
|
||||
}.commands {
|
||||
// Remove some standard menu options
|
||||
CommandGroup(replacing: .help, addition: {})
|
||||
CommandGroup(replacing: .newItem, addition: {})
|
||||
CommandGroup(replacing: .pasteboard, addition: {})
|
||||
CommandGroup(replacing: .textEditing, addition: {})
|
||||
CommandGroup(replacing: .undoRedo, addition: {})
|
||||
CommandGroup(replacing: .windowSize, addition: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MainApp.main()
|
||||
}
|
||||
}
|
||||
|
||||
struct VMView: NSViewRepresentable {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
enum CredentialsError: Error {
|
||||
case CredentialRequired(which: String)
|
||||
case CredentialTooLong(message: String)
|
||||
}
|
||||
|
||||
class Credentials {
|
||||
static func retrieveKeychain(host: String) throws -> (String, String)? {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
||||
@@ -34,13 +39,26 @@ class Credentials {
|
||||
}
|
||||
|
||||
static func retrieveStdin() throws -> (String, String) {
|
||||
print("User: ", terminator: "")
|
||||
let user = readLine() ?? ""
|
||||
let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false)
|
||||
let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true)
|
||||
|
||||
let rawPass = getpass("Password: ")
|
||||
let pass = String(cString: rawPass!, encoding: .utf8)!
|
||||
return (user, password)
|
||||
}
|
||||
|
||||
return (user, pass)
|
||||
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 255, isSensitive: Bool) throws -> String {
|
||||
var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */)
|
||||
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
|
||||
throw CredentialsError.CredentialRequired(which: name)
|
||||
}
|
||||
|
||||
let credential = String(cString: rawCredential).trimmingCharacters(in: .newlines)
|
||||
|
||||
if credential.count > maxCharacters {
|
||||
throw CredentialsError.CredentialTooLong(
|
||||
message: "\(name) should contain no more than \(maxCharacters) characters")
|
||||
}
|
||||
|
||||
return credential
|
||||
}
|
||||
|
||||
static func store(host: String, user: String, password: String) throws {
|
||||
|
||||
@@ -8,11 +8,31 @@ enum RegistryError: Error {
|
||||
}
|
||||
|
||||
struct TokenResponse: Decodable {
|
||||
let creationTime = Date()
|
||||
|
||||
let defaultIssuedAt = Date()
|
||||
let defaultExpiresIn = 60
|
||||
|
||||
var token: String
|
||||
var expires_in: Int?
|
||||
|
||||
var expiresIn: Int?
|
||||
var issuedAt: Date?
|
||||
|
||||
static func parse(fromData: Data) throws -> Self {
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
|
||||
// RFC3339 date formatter from Apple's documentation[1]
|
||||
//
|
||||
// [1]: https://developer.apple.com/documentation/foundation/dateformatter
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
|
||||
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
|
||||
decoder.dateDecodingStrategy = .formatted(dateFormatter)
|
||||
|
||||
return try decoder.decode(TokenResponse.self, from: fromData)
|
||||
}
|
||||
|
||||
var tokenExpiresAt: Date {
|
||||
get {
|
||||
// Tokens can expire and expire_in field is used to determine when:
|
||||
@@ -22,8 +42,8 @@ struct TokenResponse: Decodable {
|
||||
// >a token should never be returned with less than 60 seconds to live.
|
||||
//
|
||||
// [1]: https://docs.docker.com/registry/spec/auth/token/#requesting-a-token
|
||||
|
||||
creationTime + TimeInterval(expires_in ?? 60)
|
||||
|
||||
(issuedAt ?? defaultIssuedAt) + TimeInterval(expiresIn ?? defaultExpiresIn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +253,7 @@ class Registry {
|
||||
+ "while retrieving an authentication token", details: String(decoding: tokenResponseRaw, as: UTF8.self))
|
||||
}
|
||||
|
||||
currentAuthToken = try JSONDecoder().decode(TokenResponse.self, from: tokenResponseRaw)
|
||||
currentAuthToken = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||
}
|
||||
|
||||
private func authAwareRequest(request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
|
||||
@@ -34,6 +34,7 @@ struct RemoteName: Comparable, CustomStringConvertible {
|
||||
init(_ name: String) throws {
|
||||
let csNormal = [
|
||||
UInt8(ascii: "a")...UInt8(ascii: "z"),
|
||||
UInt8(ascii: "A")...UInt8(ascii: "Z"),
|
||||
UInt8(ascii: "0")...UInt8(ascii: "9"),
|
||||
].asCharacterSet().union(CharacterSet(charactersIn: "_-."))
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct Root: AsyncParsableCommand {
|
||||
@@ -16,4 +17,27 @@ struct Root: AsyncParsableCommand {
|
||||
Push.self,
|
||||
Delete.self,
|
||||
])
|
||||
|
||||
public static func main() async throws {
|
||||
// Handle cancellation by Ctrl+C
|
||||
let task = withUnsafeCurrentTask { $0 }!
|
||||
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
|
||||
sigintSrc.setEventHandler {
|
||||
task.cancel()
|
||||
}
|
||||
sigintSrc.activate()
|
||||
|
||||
// Parse and run command
|
||||
do {
|
||||
var command = try parseAsRoot()
|
||||
|
||||
if var asyncCommand = command as? AsyncParsableCommand {
|
||||
try await asyncCommand.run()
|
||||
} else {
|
||||
try command.run()
|
||||
}
|
||||
} catch {
|
||||
exit(withError: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-2
@@ -140,7 +140,19 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
sema.wait()
|
||||
await withTaskCancellationHandler(operation: {
|
||||
sema.wait()
|
||||
}, onCancel: {
|
||||
sema.signal()
|
||||
})
|
||||
|
||||
if Task.isCancelled {
|
||||
DispatchQueue.main.sync {
|
||||
Task {
|
||||
try await self.virtualMachine.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func craftConfiguration(diskURL: URL, auxStorage: VZMacAuxiliaryStorage, vmConfig: VMConfig) throws -> VZVirtualMachineConfiguration {
|
||||
@@ -173,6 +185,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
]
|
||||
configuration.graphicsDevices = [graphicsDeviceConfiguration]
|
||||
|
||||
// Audio
|
||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||
soundDeviceConfiguration.streams = [VZVirtioSoundDeviceInputStreamConfiguration(), VZVirtioSoundDeviceOutputStreamConfiguration()]
|
||||
configuration.audioDevices = [soundDeviceConfiguration]
|
||||
|
||||
// Keyboard and mouse
|
||||
configuration.keyboards = [VZUSBKeyboardConfiguration()]
|
||||
configuration.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
@@ -207,7 +224,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
func virtualMachine(_ virtualMachine: VZVirtualMachine, networkDevice: VZNetworkDevice, attachmentWasDisconnectedWithError error: Error) {
|
||||
print("virtual machine's network attachment has been disconnected")
|
||||
print("virtual machine's network attachment \(networkDevice) has been disconnected with error: \(error)")
|
||||
sema.signal()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,9 +36,13 @@ class VMStorageLocal {
|
||||
return try FileManager.default.contentsOfDirectory(
|
||||
at: baseURL,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: .skipsSubdirectoryDescendants).map { url in
|
||||
options: .skipsSubdirectoryDescendants).compactMap { url in
|
||||
let vmDir = VMDirectory(baseURL: url)
|
||||
|
||||
if !vmDir.initialized {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (vmDir.name, vmDir)
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -7,6 +7,12 @@ final class RemoteNameTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:latest"))
|
||||
}
|
||||
|
||||
func testComplexTag() throws {
|
||||
let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: "1.2.3-RC-1")
|
||||
|
||||
XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:1.2.3-RC-1"))
|
||||
}
|
||||
|
||||
func testDigest() throws {
|
||||
let expectedRemoteName = RemoteName(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class TokenResponseTests: XCTestCase {
|
||||
func testBasic() throws {
|
||||
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
|
||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||
|
||||
XCTAssertEqual(tokenResponse.token, "some token")
|
||||
|
||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(60)
|
||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||
|
||||
XCTAssertTrue(tokenResponse.isValid)
|
||||
}
|
||||
|
||||
func testExpirationBasic() throws {
|
||||
let tokenResponseRaw = Data("{\"token\":\"some token\",\"expires_in\":2}".utf8)
|
||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||
|
||||
XCTAssertEqual(tokenResponse.expiresIn, 2)
|
||||
|
||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(2)
|
||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||
|
||||
XCTAssertTrue(tokenResponse.isValid)
|
||||
_ = XCTWaiter.wait(for: [expectation(description: "Wait 3 seconds for the token to become invalid")], timeout: 2)
|
||||
XCTAssertFalse(tokenResponse.isValid)
|
||||
}
|
||||
|
||||
func testExpirationWithIssuedAt() throws {
|
||||
let tokenResponseRaw = Data("{\"token\":\"some token\",\"expires_in\":3600,\"issued_at\":\"1970-01-01T00:00:00Z\"}".utf8)
|
||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||
|
||||
XCTAssertEqual(Date(timeIntervalSince1970: 3600), tokenResponse.tokenExpiresAt)
|
||||
XCTAssertFalse(tokenResponse.isValid)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user