Compare commits

..
18 Commits
Author SHA1 Message Date
Nikolay Edigaryev b581db5be4 OCI: make annotations optional (#130)
* OCI: make annotations optional

* Don't initialize annotations by default
2022-06-17 10:53:52 -04:00
Fedor Korotkov 8ed2ce159f Install Go 2022-06-17 10:33:32 -04:00
Fedor Korotkov 89217c23a2 Fixes for Ventura (#129)
* Fixes for Ventura

Still a noob in SwiftUI and Swift concurrency, but it seems on Ventura a task group is not actually running on main or something. Either way I think this change simplifies things but launching a VM in a task and then just continuing with either VNC or built-in graphics.

* Check VM's state
2022-06-17 10:04:57 -04:00
Fedor Korotkov 0d633de04a Build Tart inside a Tart VM (#127) 2022-06-17 11:18:26 +03:00
Nikolay Edigaryev f59ef2722d OCI: store uncompressed disk size in manifest (#128) 2022-06-17 11:14:28 +03:00
Fedor Korotkov 7759c72a76 Don't use dispatchMain (#123) 2022-06-14 11:31:31 -04:00
Raymond 4cc8a9925a accept larger disk size (#122) 2022-06-10 09:05:57 -04:00
Nikolay Edigaryev b16bbf587a Registry: log upload location when encountering blob pushing error (#121) 2022-06-08 17:23:16 +03:00
Fedor Korotkov 022317bc17 Build Debug Binary (#117)
* Build Debug Binary

Xcode 14 Beta breaks something when building with a production configuration and Tart can't run a VM.

* Fixed path
2022-06-07 18:14:52 +03:00
Fedor Korotkov 87733290e7 Smaller icon (#116)
Initially it wasn't fully following the [guidelines](https://developer.apple.com/design/human-interface-guidelines/macos/icons-and-images/app-icon/).

Fixes #112
2022-06-06 18:38:30 +03:00
Fedor Korotkov c14b46adc3 Make sure VM directory exists before moving (#111) 2022-06-03 23:22:11 +03:00
Nikolay Edigaryev 63329ef363 Atomic tart {create,clone,pull} operations using rename(2) (#109)
* tart clone: always re-generate MAC-address

This is not really an issue for non-enterprise users[1].

[1]: https://github.com/cirruslabs/tart/issues/20#issuecomment-1136944455

* Atomic tart {create,clone,pull} operations using rename(2)

* Print a nicer error message when attempting to double-run a VM

* tart clone: bring back the old MAC-address generation logic

* Ensure VMDirectory.temporary() will be deleted on failure
2022-06-01 11:02:31 -04:00
Nikolay EdigaryevandFedor Korotkov 5446164a36 tart pull: introduce --populate-cache flag (#103)
* tart pull: introduce --populate-cache flag

* VMStorageOCI: introduce cache() method

* Review comments (#107)

* Rename SetCommand back to Set

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2022-05-28 23:20:24 -04:00
Nikolay Edigaryev f3068b9055 tart login: verify credentials (#102)
* tart login: verify credentials

* Make DictionaryCredentialsProvider fileprivate to Login.swift
2022-05-26 09:31:46 -04:00
Fedor Korotkov afa6b7b46c Auto-detect screen DPI (#106)
* Auto-detect screen DPI

Fixes #104

* Added a comment
2022-05-26 12:23:13 +03:00
Fedor Korotkov d277fb2941 Added helpfull messages to run command (#100) 2022-05-23 11:29:35 -04:00
Fedor Korotkov 088cdc51a3 Option to run with VNC (#97)
* Option to run with VNC

So copy/paste is working. For example, `tart run --vnc latest`.

Related to 14

* Add VNC task first

* Indicate the wait
2022-05-23 16:49:15 +03:00
Fedor Korotkov afb23a3e3a Init HTTP client only if Registry is used (#99) 2022-05-21 00:26:43 +03:00
26 changed files with 455 additions and 214 deletions
+13 -5
View File
@@ -1,14 +1,16 @@
persistent_worker:
labels:
name: Mac-Mini-M1
task:
name: Test
name: Test on Ventura
persistent_worker:
labels:
name: Mac-Mini-M1
build_script: swift test
test_script: swift test
task:
name: Build
only_if: $CIRRUS_TAG == ''
macos_instance:
image: ghcr.io/cirruslabs/macos-monterey-xcode:14
build_script: swift build --product tart
sign_script: codesign --sign - --entitlements Resources/tart.entitlements --force .build/debug/tart
binary_artifacts:
@@ -17,7 +19,13 @@ task:
task:
name: Release
only_if: $CIRRUS_TAG != ''
macos_instance:
image: ghcr.io/cirruslabs/macos-monterey-xcode:14
env:
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
install_script: brew install go goreleaser/tap/goreleaser-pro
info_script:
- xcodebuild -version
- swift -version
release_script: goreleaser
+3 -3
View File
@@ -7,13 +7,13 @@ builds:
goarch:
- arm64
prebuilt:
path: .build/{{ .Arch }}-apple-macosx/release/tart
path: .build/{{ .Arch }}-apple-macosx/debug/tart
before:
hooks:
- .ci/set-version.sh
- swift build -c release --product tart
- codesign --sign - --entitlements Resources/tart.entitlements --force .build/arm64-apple-macosx/release/tart
- swift build -c debug --product tart
- codesign --sign - --entitlements Resources/tart.entitlements --force .build/arm64-apple-macosx/debug/tart
archives:
- id: binary
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8dd6af1a08bbcdc4faf0ff53601b38136c90231e11bd81bc8cd477d6f1c7d3f2
size 209404
oid sha256:1fe96aed7a965b075300f092a3ca76e09053eb7cf2f3125c3a819098a8bc4b31
size 123360
+33 -22
View File
@@ -11,24 +11,34 @@ struct Clone: AsyncParsableCommand {
@Argument(help: "new VM name")
var newName: String
func validate() throws {
if newName.contains("/") {
throw ValidationError("<new-name> should be a local name")
}
}
func run() async throws {
do {
if let remoteName = try? RemoteName(sourceName) {
if !VMStorageOCI().exists(remoteName) {
// Pull the VM in case it's OCI-based and doesn't exist locally yet
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace)
try await VMStorageOCI().pull(remoteName, registry: registry)
}
let remoteVM = try VMStorageHelper.open(sourceName)
let ociStorage = VMStorageOCI()
let localStorage = VMStorageLocal()
let remoteConfig = try VMConfig.init(fromURL: remoteVM.configURL)
let needToGenerateNewMAC = try localVMExistsWith(macAddress: remoteConfig.macAddress.string)
try remoteVM.clone(to: VMStorageLocal().create(newName), generateMAC: needToGenerateNewMAC)
} else {
try VMStorageHelper.open(sourceName).clone(to: VMStorageLocal().create(newName), generateMAC: true)
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
// Pull the VM in case it's OCI-based and doesn't exist locally yet
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace)
try await ociStorage.pull(remoteName, registry: registry)
}
let sourceVM = try VMStorageHelper.open(sourceName)
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
let tmpVMDir = try VMDirectory.temporary()
try await withTaskCancellationHandler(operation: {
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
try localStorage.move(newName, from: tmpVMDir)
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
Foundation.exit(0)
} catch {
print(error)
@@ -36,15 +46,16 @@ struct Clone: AsyncParsableCommand {
Foundation.exit(1)
}
}
}
private func localVMExistsWith(macAddress: String) throws -> Bool {
var needToGenerateNewMAC = false
for (_, localDir) in try VMStorageLocal().list() {
let localConfig = try VMConfig.init(fromURL: localDir.configURL)
if localConfig.macAddress.string == macAddress {
needToGenerateNewMAC = true
}
}
return needToGenerateNewMAC
fileprivate extension VMDirectory {
func macAddress() throws -> String {
try VMConfig(fromURL: configURL).macAddress.string
}
}
fileprivate extension VMStorageLocal {
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
try list().contains { try $1.macAddress() == macAddress }
}
}
+12 -7
View File
@@ -13,7 +13,7 @@ struct Create: AsyncParsableCommand {
var fromIPSW: String?
@Option(help: ArgumentHelp("Disk size in Gb"))
var diskSize: UInt8 = 50
var diskSize: UInt16 = 50
func validate() throws {
if fromIPSW == nil {
@@ -23,13 +23,18 @@ struct Create: AsyncParsableCommand {
func run() async throws {
do {
let vmDir = try VMStorageLocal().create(name)
let tmpVMDir = try VMDirectory.temporary()
try await withTaskCancellationHandler(operation: {
if fromIPSW! == "latest" {
_ = try await VM(vmDir: tmpVMDir, ipswURL: nil, diskSizeGB: diskSize)
} else {
_ = try await VM(vmDir: tmpVMDir, ipswURL: URL(fileURLWithPath: fromIPSW!), diskSizeGB: diskSize)
}
if fromIPSW! == "latest" {
_ = try await VM(vmDir: vmDir, ipswURL: nil, diskSizeGB: diskSize)
} else {
_ = try await VM(vmDir: vmDir, ipswURL: URL(fileURLWithPath: fromIPSW!), diskSizeGB: diskSize)
}
try VMStorageLocal().move(name, from: tmpVMDir)
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
Foundation.exit(0)
} catch {
+2 -2
View File
@@ -17,7 +17,7 @@ struct IP: AsyncParsableCommand {
let vmDir = try VMStorageLocal().open(name)
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
guard let ip = try await resolveIP(vmConfig, secondsToWait: wait) else {
guard let ip = try await IP.resolveIP(vmConfig, secondsToWait: wait) else {
print("no IP address found, is your VM running?")
Foundation.exit(1)
@@ -33,7 +33,7 @@ struct IP: AsyncParsableCommand {
}
}
private func resolveIP(_ config: VMConfig, secondsToWait: UInt16) async throws -> IPv4Address? {
static public func resolveIP(_ config: VMConfig, secondsToWait: UInt16) async throws -> IPv4Address? {
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
let vmMacAddress = MACAddress(fromString: config.macAddress.string)!
+30 -2
View File
@@ -10,9 +10,21 @@ struct Login: AsyncParsableCommand {
func run() async throws {
do {
let (user, password) = try Credentials.retrieveStdin()
let (user, password) = try StdinCredentials.retrieve()
let credentialsProvider = DictionaryCredentialsProvider([
host: (user, password)
])
try Credentials.store(host: host, user: user, password: password)
do {
let registry = try Registry(host: host, namespace: "", credentialsProvider: credentialsProvider)
try await registry.ping()
} catch {
print("invalid credentials: \(error)")
Foundation.exit(1)
}
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
Foundation.exit(0)
} catch {
@@ -22,3 +34,19 @@ struct Login: AsyncParsableCommand {
}
}
}
fileprivate class DictionaryCredentialsProvider: CredentialsProvider {
var credentials: Dictionary<String, (String, String)>
init(_ credentials: Dictionary<String, (String, String)>) {
self.credentials = credentials
}
func retrieve(host: String) throws -> (String, String)? {
credentials[host]
}
func store(host: String, user: String, password: String) throws {
credentials[host] = (user, password)
}
}
+28 -4
View File
@@ -12,6 +12,10 @@ struct Push: AsyncParsableCommand {
@Argument(help: "remote VM name(s)")
var remoteNames: [String]
@Flag(help: ArgumentHelp("cache pushed images locally",
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
var populateCache: Bool = false
func run() async throws {
do {
let localVMDir = try VMStorageLocal().open(localName)
@@ -35,12 +39,20 @@ struct Push: AsyncParsableCommand {
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace)
let listOfTagsAndDigests = "{" + remoteNamesForRegistry.map{$0.fullyQualifiedReference }
.joined(separator: ",") + "}"
defaultLogger.appendNewLine("pushing \(localName) to "
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(listOfTagsAndDigests)...")
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
try await localVMDir.pushToRegistry(registry: registry, references: remoteNamesForRegistry.map{ $0.reference })
let pushedRemoteName = try await localVMDir.pushToRegistry(registry: registry, references: remoteNamesForRegistry.map{ $0.reference.value })
// Populate the local cache (if requested)
if populateCache {
let ociStorage = VMStorageOCI()
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
for remoteName in remoteNamesForRegistry {
try ociStorage.link(from: remoteName, to: pushedRemoteName)
}
}
}
Foundation.exit(0)
@@ -51,3 +63,15 @@ struct Push: AsyncParsableCommand {
}
}
}
extension Collection where Element == RemoteName {
func referenceNames() -> String {
let references = self.map{ $0.reference.fullyQualified }
switch count {
case 0: return "∅"
case 1: return references.first!
default: return "{" + references.joined(separator: ",") + "}"
}
}
}
+47 -15
View File
@@ -5,40 +5,72 @@ import Virtualization
var vm: VM?
struct IPNotFound: Error {
}
struct Run: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Run a VM")
@Argument(help: "VM name")
var name: String
@Flag var noGraphics: Bool = false
@Flag(help: ArgumentHelp(
"Don't open a UI window.",
discussion: "Useful for integrating Tart VMs into other tools.\nUse `tart ip` in order to get an IP for SSHing or VNCing into the VM."))
var noGraphics: Bool = false
@Flag var recovery: Bool = false
@Flag(help: "Boot into recovery mode")
var recovery: Bool = false
@Flag(help: ArgumentHelp(
"Use screen sharing instead of the built-in UI.",
discussion: "Useful since VNC supports copy/paste, drag and drop, etc.\nNote that Remote Login option should be enabled inside the VM."))
var vnc: Bool = false
@MainActor
func run() async throws {
if recovery && vnc {
print("You can't run in recovery and use VNC!")
Foundation.exit(1)
}
let vmDir = try VMStorageLocal().open(name)
vm = try VM(vmDir: vmDir)
await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
do {
try await vm!.run(recovery)
Task {
do {
try await vm!.run(recovery)
Foundation.exit(0)
} catch {
Foundation.exit(0)
} catch {
if error.localizedDescription.contains("Failed to lock auxiliary storage.") {
print("Virtual machine \"\(name)\" is already running!")
} else {
print(error)
Foundation.exit(1)
}
}
if noGraphics {
dispatchMain()
} else {
runUI()
Foundation.exit(1)
}
}
if vnc {
do {
print("Waiting for the VM to boot...")
let resolvedIP = try await IP.resolveIP(vm!.config, secondsToWait: 60)
guard let ip = resolvedIP else {
throw IPNotFound()
}
let url = URL(string: "vnc://\(ip)")!
print("Opening \(url)")
NSWorkspace.shared.open(url)
} catch {
print("Failed to get an IP for screen sharing: \(error)")
}
while !(vm?.inFinalState ?? false) {
try await Task.sleep(nanoseconds: 1_000_000)
}
} else if !noGraphics {
runUI()
}
}
private func runUI() {
+4 -8
View File
@@ -2,7 +2,7 @@ import ArgumentParser
import Foundation
struct Set: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Modify VM's configuration")
static var configuration = CommandConfiguration(commandName: "set", abstract: "Modify VM's configuration")
@Argument(help: "VM name")
var name: String
@@ -13,11 +13,11 @@ struct Set: AsyncParsableCommand {
@Option(help: "VM memory size in megabytes")
var memory: UInt16?
@Option(help: "VM display settings in a format of <width>x<height>(x<dpi>)?. For example, 1200x800 or 1200x800x72")
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
var display: VMDisplayConfig?
@Option(help: .hidden)
var diskSize: UInt8?
var diskSize: UInt16?
func run() async throws {
do {
@@ -39,9 +39,6 @@ struct Set: AsyncParsableCommand {
if (display.height > 0) {
vmConfig.display.height = display.height
}
if (display.dpi > 0) {
vmConfig.display.dpi = display.dpi
}
}
try vmConfig.save(toURL: vmDir.configURL)
@@ -66,8 +63,7 @@ extension VMDisplayConfig: ExpressibleByArgument {
}
self = VMDisplayConfig(
width: parts[safe: 0] ?? 0,
height: parts[safe: 1] ?? 0,
dpi: parts[safe: 2] ?? 0
height: parts[safe: 1] ?? 0
)
}
}
-82
View File
@@ -1,82 +0,0 @@
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,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
kSecReturnData as String: true,
kSecAttrLabel as String: "Tart Credentials",
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status != errSecSuccess {
if status == errSecItemNotFound {
return nil
}
throw RegistryError.AuthFailed(why: "Keychain returned unsuccessful status \(status)")
}
guard let item = item as? [String: Any],
let user = item[kSecAttrAccount as String] as? String,
let passwordData = item[kSecValueData as String] as? Data,
let password = String(data: passwordData, encoding: .utf8)
else {
throw RegistryError.AuthFailed(why: "Keychain item has unexpected format")
}
return (user, password)
}
static func retrieveStdin() throws -> (String, String) {
let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false)
let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true)
return (user, password)
}
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 {
let attributes: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrAccount as String: user,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecValueData as String: password,
kSecAttrLabel as String: "Tart Credentials",
]
let status = SecItemAdd(attributes as CFDictionary, nil)
switch status {
case errSecSuccess, errSecDuplicateItem:
return
default:
throw RegistryError.AuthFailed(why: "Keychain returned unsuccessful status \(status)")
}
}
}
@@ -0,0 +1,10 @@
import Foundation
enum CredentialsProviderError: Error {
case Failed(message: String)
}
protocol CredentialsProvider {
func retrieve(host: String) throws -> (String, String)?
func store(host: String, user: String, password: String) throws
}
@@ -0,0 +1,54 @@
import Foundation
class KeychainCredentialsProvider: CredentialsProvider {
func retrieve(host: String) throws -> (String, String)? {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
kSecReturnData as String: true,
kSecAttrLabel as String: "Tart Credentials",
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status != errSecSuccess {
if status == errSecItemNotFound {
return nil
}
throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)")
}
guard let item = item as? [String: Any],
let user = item[kSecAttrAccount as String] as? String,
let passwordData = item[kSecValueData as String] as? Data,
let password = String(data: passwordData, encoding: .utf8)
else {
throw CredentialsProviderError.Failed(message: "Keychain item has unexpected format")
}
return (user, password)
}
func store(host: String, user: String, password: String) throws {
let attributes: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrAccount as String: user,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecValueData as String: password,
kSecAttrLabel as String: "Tart Credentials",
]
let status = SecItemAdd(attributes as CFDictionary, nil)
switch status {
case errSecSuccess, errSecDuplicateItem:
return
default:
throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)")
}
}
}
@@ -0,0 +1,31 @@
import Foundation
enum StdinCredentialsError: Error {
case CredentialRequired(which: String)
case CredentialTooLong(message: String)
}
class StdinCredentials {
static func retrieve() throws -> (String, String) {
let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false)
let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true)
return (user, password)
}
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 StdinCredentialsError.CredentialRequired(which: name)
}
let credential = String(cString: rawCredential).trimmingCharacters(in: .newlines)
if credential.count > maxCharacters {
throw StdinCredentialsError.CredentialTooLong(
message: "\(name) should contain no more than \(maxCharacters) characters")
}
return credential
}
}
File diff suppressed because one or more lines are too long
+27
View File
@@ -3,11 +3,38 @@ import Foundation
let ociManifestMediaType = "application/vnd.oci.image.manifest.v1+json"
let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
// Annotations
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
struct OCIManifest: Codable, Equatable {
var schemaVersion: Int = 2
var mediaType: String = ociManifestMediaType
var config: OCIManifestConfig
var layers: [OCIManifestLayer] = Array()
var annotations: Dictionary<String, String>?
init(config: OCIManifestConfig, layers: [OCIManifestLayer], uncompressedDiskSize: UInt64? = nil) {
self.config = config
self.layers = layers
if let uncompressedDiskSize = uncompressedDiskSize {
annotations = [
uncompressedDiskSizeAnnotation: String(uncompressedDiskSize)
]
}
}
func digest() throws -> String {
try Digest.hash(JSONEncoder().encode(self))
}
func uncompressedDiskSize() -> UInt64? {
guard let value = annotations?[uncompressedDiskSizeAnnotation] else {
return nil
}
return UInt64(value)
}
}
struct OCIManifestConfig: Codable, Equatable {
+23 -10
View File
@@ -72,27 +72,40 @@ struct TokenResponse: Decodable {
}
}
fileprivate let httpClient = HTTPClient(eventLoopGroupProvider: .createNew)
class Registry {
var baseURL: URL
var namespace: String
private let httpClient = HTTPClient(eventLoopGroupProvider: .createNew)
deinit {
try! httpClient.syncShutdown()
}
let baseURL: URL
let namespace: String
let credentialsProvider: CredentialsProvider
var currentAuthToken: TokenResponse? = nil
init(urlComponents: URLComponents, namespace: String) throws {
init(urlComponents: URLComponents,
namespace: String,
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider()
) throws {
baseURL = urlComponents.url!
self.namespace = namespace
self.credentialsProvider = credentialsProvider
}
convenience init(host: String, namespace: String) throws {
convenience init(
host: String,
namespace: String,
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider()
) throws {
var baseURLComponents = URLComponents()
baseURLComponents.scheme = "https"
baseURLComponents.host = host
baseURLComponents.path = "/v2/"
try self.init(urlComponents: baseURLComponents, namespace: namespace)
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProvider: credentialsProvider)
}
func ping() async throws {
@@ -171,8 +184,8 @@ class Registry {
body: fromData)
if putResponse.status != .created {
let body = try await postResponse.body.readTextResponse()
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT)", code: putResponse.status.code,
details: body ?? "")
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
code: putResponse.status.code, details: body ?? "")
}
return digest
@@ -281,7 +294,7 @@ class Registry {
var headers: Dictionary<String, String> = Dictionary()
if let (user, password) = try Credentials.retrieveKeychain(host: baseURL.host!) {
if let (user, password) = try credentialsProvider.retrieve(host: baseURL.host!) {
let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString()
headers["Authorization"] = "Basic \(encodedCredentials!)"
}
+45 -21
View File
@@ -1,31 +1,57 @@
import Foundation
import Parsing
struct Tail {
enum TailType {
struct Reference: Comparable, Hashable, CustomStringConvertible {
enum ReferenceType: Comparable {
case Tag
case Digest
}
var type: TailType
var value: String
}
let type: ReferenceType
let value: String
struct RemoteName: Comparable, CustomStringConvertible {
var host: String
var namespace: String
var reference: String = "latest"
var fullyQualifiedReference: String {
var fullyQualified: String {
get {
if reference.starts(with: "sha256:") {
return "@" + reference
switch type {
case .Tag:
return ":" + value
case .Digest:
return "@" + value
}
return ":" + reference
}
}
init(host: String, namespace: String, reference: String) {
init(tag: String) {
type = .Tag
value = tag
}
init(digest: String) {
type = .Digest
value = digest
}
static func <(lhs: Reference, rhs: Reference) -> Bool {
if lhs.type != rhs.type {
return lhs.type < rhs.type
} else {
return lhs.value < rhs.value
}
}
var description: String {
get {
fullyQualified
}
}
}
struct RemoteName: Comparable, Hashable, CustomStringConvertible {
var host: String
var namespace: String
var reference: Reference
init(host: String, namespace: String, reference: Reference) {
self.host = host
self.namespace = namespace
self.reference = reference
@@ -58,13 +84,13 @@ struct RemoteName: Comparable, CustomStringConvertible {
Parse {
":"
csNormal.map {
Tail(type: .Tag, value: String($0))
Reference(tag: String($0))
}
}
Parse {
"@sha256:"
csHex.map {
Tail(type: .Digest, value: "sha256:" + String($0))
Reference(digest: "sha256:" + String($0))
}
}
}
@@ -76,9 +102,7 @@ struct RemoteName: Comparable, CustomStringConvertible {
host = String(result.0)
namespace = String(result.1)
if let tail = result.2 {
reference = tail.value
}
reference = result.2 ?? Reference(tag: "latest")
}
static func <(lhs: RemoteName, rhs: RemoteName) -> Bool {
@@ -92,7 +116,7 @@ struct RemoteName: Comparable, CustomStringConvertible {
}
var description: String {
"\(host)/\(namespace)\(fullyQualifiedReference)"
"\(host)/\(namespace)\(reference.fullyQualified)"
}
}
+5 -1
View File
@@ -20,11 +20,15 @@ struct Root: AsyncParsableCommand {
])
public static func main() async throws {
// Handle cancellation by Ctrl+C
// Ensure the default SIGINT handled is disabled,
// otherwise there's a race between two handlers
signal(SIGINT, SIG_IGN);
// Handle cancellation by Ctrl+C ourselves
let task = withUnsafeCurrentTask { $0 }!
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
sigintSrc.setEventHandler {
task.cancel()
Darwin.exit(1)
}
sigintSrc.activate()
+27 -7
View File
@@ -77,8 +77,17 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
try data.write(to: expectedIPSWLocation, options: [.atomic])
return expectedIPSWLocation
}
var inFinalState: Bool {
get {
virtualMachine.state == VZVirtualMachine.State.stopped ||
virtualMachine.state == VZVirtualMachine.State.paused ||
virtualMachine.state == VZVirtualMachine.State.error
}
}
init(vmDir: VMDirectory, ipswURL: URL?, diskSizeGB: UInt8) async throws {
init(vmDir: VMDirectory, ipswURL: URL?, diskSizeGB: UInt16) async throws {
let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW();
// Load the restore image and try to get the requirements
@@ -172,13 +181,24 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Display
let graphicsDeviceConfiguration = VZMacGraphicsDeviceConfiguration()
graphicsDeviceConfiguration.displays = [
VZMacGraphicsDisplayConfiguration(
widthInPixels: vmConfig.display.width,
heightInPixels: vmConfig.display.height,
pixelsPerInch: vmConfig.display.dpi
if let hostMainScreen = NSScreen.main {
let vmScreenSize = NSSize(
width: vmConfig.display.width,
height: vmConfig.display.height
)
]
graphicsDeviceConfiguration.displays = [
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
]
} else {
graphicsDeviceConfiguration.displays = [
VZMacGraphicsDisplayConfiguration(
widthInPixels: vmConfig.display.width,
heightInPixels: vmConfig.display.height,
// Reasonable guess like https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
pixelsPerInch: 72
)
]
}
configuration.graphicsDevices = [graphicsDeviceConfiguration]
// Audio
-1
View File
@@ -29,7 +29,6 @@ enum CodingKeys: String, CodingKey {
struct VMDisplayConfig: Codable {
var width: Int = 1024
var height: Int = 768
var dpi: Int = 72
}
struct VMConfig: Codable {
+8 -2
View File
@@ -98,7 +98,7 @@ extension VMDirectory {
try nvram.close()
}
func pushToRegistry(registry: Registry, references: [String]) async throws {
func pushToRegistry(registry: Registry, references: [String]) async throws -> RemoteName {
var layers = Array<OCIManifestLayer>()
// Read VM's config and push it as blob
@@ -117,8 +117,10 @@ extension VMDirectory {
// Read VM's compressed disk as chunks
// and sequentially upload them as blobs
let disk = try FileHandle(forReadingFrom: diskURL)
var diskReadBytes: UInt64 = 0
let compressingFilter = try InputFilter<Data>(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { _ in
let data = try disk.read(upToCount: Self.bufferSizeBytes)
diskReadBytes += UInt64(data?.count ?? 0)
progress.completedUnitCount += Int64(data?.count ?? 0)
@@ -146,7 +148,8 @@ extension VMDirectory {
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON)
let manifest = OCIManifest(
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
layers: layers
layers: layers,
uncompressedDiskSize: diskReadBytes
)
// Manifest
@@ -155,6 +158,9 @@ extension VMDirectory {
_ = try await registry.pushManifest(reference: reference, manifest: manifest)
}
let pushedReference = Reference(digest: try manifest.digest())
return RemoteName(host: registry.baseURL.host!, namespace: registry.namespace, reference: pushedReference)
}
}
+8 -1
View File
@@ -24,6 +24,13 @@ struct VMDirectory {
baseURL.lastPathComponent
}
static func temporary() throws -> VMDirectory {
let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: false)
return VMDirectory(baseURL: tmpDir)
}
var initialized: Bool {
FileManager.default.fileExists(atPath: configURL.path) &&
FileManager.default.fileExists(atPath: diskURL.path) &&
@@ -61,7 +68,7 @@ struct VMDirectory {
try newVMConfig.save(toURL: to.configURL)
}
func resizeDisk(_ sizeGB: UInt8) throws {
func resizeDisk(_ sizeGB: UInt16) throws {
if !FileManager.default.fileExists(atPath: diskURL.path) {
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
}
+5
View File
@@ -27,6 +27,11 @@ class VMStorageLocal {
return vmDir
}
func move(_ name: String, from: VMDirectory) throws {
_ = try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
_ = try FileManager.default.replaceItemAt(vmURL(name), withItemAt: from.baseURL)
}
func delete(_ name: String) throws {
try FileManager.default.removeItem(at: vmURL(name))
}
+34 -15
View File
@@ -27,6 +27,17 @@ class VMStorageOCI {
return vmDir
}
func move(_ name: RemoteName, from: VMDirectory) throws{
let targetURL = vmURL(name)
// Pre-create intermediate directories (e.g. creates ~/.tart/cache/OCIs/github.com/org/repo/
// for github.com/org/repo:latest)
try FileManager.default.createDirectory(at: targetURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
_ = try FileManager.default.replaceItemAt(targetURL, withItemAt: from.baseURL)
}
func delete(_ name: RemoteName) throws {
try FileManager.default.removeItem(at: vmURL(name))
}
@@ -64,35 +75,43 @@ class VMStorageOCI {
func pull(_ name: RemoteName, registry: Registry) async throws {
defaultLogger.appendNewLine("pulling manifest...")
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference)
let (manifest, _) = try await registry.pullManifest(reference: name.reference.value)
var digestName = RemoteName(host: name.host, namespace: name.namespace,
reference: Reference(digest: try manifest.digest()))
// Create directory for manifest's digest
var digestName = name
digestName.reference = Digest.hash(manifestData)
if !exists(digestName) {
let vmDir = try create(digestName)
try await vmDir.pullFromRegistry(registry: registry, manifest: manifest)
let tmpVMDir = try VMDirectory.temporary()
try await withTaskCancellationHandler(operation: {
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
try move(digestName, from: tmpVMDir)
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
} else {
defaultLogger.appendNewLine("\(digestName.reference) image is already cached! creating a symlink...")
defaultLogger.appendNewLine("\(digestName) image is already cached! creating a symlink...")
}
// Create directory for reference if it's different
if digestName != name {
if name != digestName {
// Overwrite the old symbolic link
if FileManager.default.fileExists(atPath: vmURL(name).path) {
try FileManager.default.removeItem(at: vmURL(name))
}
try FileManager.default.createSymbolicLink(at: vmURL(name), withDestinationURL: vmURL(digestName))
try link(from: digestName, to: name)
}
}
func link(from: RemoteName, to: RemoteName) throws {
if FileManager.default.fileExists(atPath: vmURL(to).path) {
try FileManager.default.removeItem(at: vmURL(to))
}
try FileManager.default.createSymbolicLink(at: vmURL(to), withDestinationURL: vmURL(from))
}
}
extension URL {
func appendingRemoteName(_ name: RemoteName) -> URL {
var result: URL = self
for pathComponent in (name.host + "/" + name.namespace + "/" + name.reference).split(separator: "/") {
for pathComponent in (name.host + "/" + name.namespace + "/" + name.reference.value).split(separator: "/") {
result = result.appendingPathComponent(String(pathComponent))
}
+3 -3
View File
@@ -3,13 +3,13 @@ import XCTest
final class RemoteNameTests: XCTestCase {
func testTag() throws {
let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: "latest")
let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: Reference(tag: "latest"))
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")
let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: Reference(tag: "1.2.3-RC-1"))
XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:1.2.3-RC-1"))
}
@@ -18,7 +18,7 @@ final class RemoteNameTests: XCTestCase {
let expectedRemoteName = RemoteName(
host: "ghcr.io",
namespace: "a/b",
reference: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
reference: Reference(digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
)
XCTAssertEqual(expectedRemoteName,