Compare commits

...
11 Commits
Author SHA1 Message Date
Fedor Korotkov 63a2793c32 Support upper case in remote names (#79) 2022-05-16 18:14:42 +03:00
Fedor Korotkov 0fc3d3d1f4 Add virtual sound devices (#78) 2022-05-16 17:21:21 +03:00
Nikolay Edigaryev 60b705478b Handle Ctrl + C properly (#73)
* Terminate when Ctrl+C is encountered when entering credentials

* Handle Ctrl+C by catching SIGINT and converting it to task cancellation

* maxCharacters instead of (buf.count - 2)

* readStdinCredential: fix maxCharacters to be 255

* "user" variable should be named "credential"
2022-05-16 09:56:30 -04:00
Nikolay Edigaryev fa9e3146c1 tart list: filter out uninitialized VM directories (#76) 2022-05-16 08:30:58 -04:00
Nikolay Edigaryev 3dfe8f870c Provide more details when VM's network attachment disconnects (#74) 2022-05-16 08:23:34 -04:00
Fedor Korotkov 7afa446a73 Change default disk size upon creation (#71)
To help with frustrations like https://github.com/cirruslabs/tart/issues/44#issuecomment-1126831261

Experts will still use `--disk-size` flag to tailor disk size for their needs.
2022-05-16 09:44:31 +03:00
Nikolay EdigaryevandFedor Korotkov d1bed25023 tart pull: be more liberal when accepting local image as argument (#70)
* tart pull: be more liberal when accepting local image as argument

* Update Sources/tart/Commands/Pull.swift

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>

* Single sentence and consistent capitalization

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2022-05-15 18:03:15 -04:00
Fedor Korotkov b39c3c9b52 Reworked CI integration documentation (#69)
It was a bit confusing to folks not familiar with Cirrus CLI. Reworked the documentation to better introduce Cirrus CLI.
2022-05-15 13:25:25 +03:00
Nikolay Edigaryev 8554e56e4b Registry: use issued_at field in the token response message (#65)
* Registry: use issued_at field in the token response message

* Configure JSONDecoder instead of the Decodable itself
2022-05-12 22:33:02 +03:00
Nikolay Edigaryev 3fdcc5c33c .goreleaser(brews): depend on macOS Monterey (#64)
* .goreleaser(brews): depend on macOS Monterey

* README.md: mention that macOS Monterey or later is required
2022-05-12 00:42:12 +03:00
Austin Culter 182ddf0268 Minor typo: 'reposiotry' > 'repository' (#61)
On the tin.
2022-05-10 17:16:03 -04:00
13 changed files with 207 additions and 60 deletions
+2
View File
@@ -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
+10 -6
View File
@@ -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
+1 -1
View File
@@ -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 {
+8
View File
@@ -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)
+43 -38
View File
@@ -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 {
+23 -5
View File
@@ -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 {
+27 -7
View File
@@ -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) {
+1
View File
@@ -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: "_-."))
+24
View File
@@ -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
View File
@@ -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()
}
}
+5 -1
View File
@@ -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 {
+6
View File
@@ -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(
+38
View File
@@ -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)
}
}