mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-19 07:31:59 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87733290e7 | ||
|
|
c14b46adc3 | ||
|
|
63329ef363 | ||
|
|
5446164a36 | ||
|
|
f3068b9055 | ||
|
|
afa6b7b46c | ||
|
|
d277fb2941 | ||
|
|
088cdc51a3 | ||
|
|
afb23a3e3a |
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8dd6af1a08bbcdc4faf0ff53601b38136c90231e11bd81bc8cd477d6f1c7d3f2
|
oid sha256:1fe96aed7a965b075300f092a3ca76e09053eb7cf2f3125c3a819098a8bc4b31
|
||||||
size 209404
|
size 123360
|
||||||
|
|||||||
@@ -11,24 +11,34 @@ struct Clone: AsyncParsableCommand {
|
|||||||
@Argument(help: "new VM name")
|
@Argument(help: "new VM name")
|
||||||
var newName: String
|
var newName: String
|
||||||
|
|
||||||
|
func validate() throws {
|
||||||
|
if newName.contains("/") {
|
||||||
|
throw ValidationError("<new-name> should be a local name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
do {
|
||||||
if let remoteName = try? RemoteName(sourceName) {
|
let ociStorage = VMStorageOCI()
|
||||||
if !VMStorageOCI().exists(remoteName) {
|
let localStorage = VMStorageLocal()
|
||||||
// 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 remoteConfig = try VMConfig.init(fromURL: remoteVM.configURL)
|
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
||||||
let needToGenerateNewMAC = try localVMExistsWith(macAddress: remoteConfig.macAddress.string)
|
// 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 remoteVM.clone(to: VMStorageLocal().create(newName), generateMAC: needToGenerateNewMAC)
|
try await ociStorage.pull(remoteName, registry: registry)
|
||||||
} else {
|
|
||||||
try VMStorageHelper.open(sourceName).clone(to: VMStorageLocal().create(newName), generateMAC: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
Foundation.exit(0)
|
||||||
} catch {
|
} catch {
|
||||||
print(error)
|
print(error)
|
||||||
@@ -36,15 +46,16 @@ struct Clone: AsyncParsableCommand {
|
|||||||
Foundation.exit(1)
|
Foundation.exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func localVMExistsWith(macAddress: String) throws -> Bool {
|
fileprivate extension VMDirectory {
|
||||||
var needToGenerateNewMAC = false
|
func macAddress() throws -> String {
|
||||||
for (_, localDir) in try VMStorageLocal().list() {
|
try VMConfig(fromURL: configURL).macAddress.string
|
||||||
let localConfig = try VMConfig.init(fromURL: localDir.configURL)
|
}
|
||||||
if localConfig.macAddress.string == macAddress {
|
}
|
||||||
needToGenerateNewMAC = true
|
|
||||||
}
|
fileprivate extension VMStorageLocal {
|
||||||
}
|
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
|
||||||
return needToGenerateNewMAC
|
try list().contains { try $1.macAddress() == macAddress }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,13 +23,18 @@ struct Create: AsyncParsableCommand {
|
|||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
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 VMStorageLocal().move(name, from: tmpVMDir)
|
||||||
_ = try await VM(vmDir: vmDir, ipswURL: nil, diskSizeGB: diskSize)
|
}, onCancel: {
|
||||||
} else {
|
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||||
_ = try await VM(vmDir: vmDir, ipswURL: URL(fileURLWithPath: fromIPSW!), diskSizeGB: diskSize)
|
})
|
||||||
}
|
|
||||||
|
|
||||||
Foundation.exit(0)
|
Foundation.exit(0)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ struct IP: AsyncParsableCommand {
|
|||||||
let vmDir = try VMStorageLocal().open(name)
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
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?")
|
print("no IP address found, is your VM running?")
|
||||||
|
|
||||||
Foundation.exit(1)
|
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 waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
|
||||||
let vmMacAddress = MACAddress(fromString: config.macAddress.string)!
|
let vmMacAddress = MACAddress(fromString: config.macAddress.string)!
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,21 @@ struct Login: AsyncParsableCommand {
|
|||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
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)
|
Foundation.exit(0)
|
||||||
} catch {
|
} 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ struct Push: AsyncParsableCommand {
|
|||||||
@Argument(help: "remote VM name(s)")
|
@Argument(help: "remote VM name(s)")
|
||||||
var remoteNames: [String]
|
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 {
|
func run() async throws {
|
||||||
do {
|
do {
|
||||||
let localVMDir = try VMStorageLocal().open(localName)
|
let localVMDir = try VMStorageLocal().open(localName)
|
||||||
@@ -35,12 +39,20 @@ struct Push: AsyncParsableCommand {
|
|||||||
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
|
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
|
||||||
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace)
|
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace)
|
||||||
|
|
||||||
let listOfTagsAndDigests = "{" + remoteNamesForRegistry.map{$0.fullyQualifiedReference }
|
|
||||||
.joined(separator: ",") + "}"
|
|
||||||
defaultLogger.appendNewLine("pushing \(localName) to "
|
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)
|
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: ",") + "}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,35 +5,72 @@ import Virtualization
|
|||||||
|
|
||||||
var vm: VM?
|
var vm: VM?
|
||||||
|
|
||||||
|
struct IPNotFound: Error {
|
||||||
|
}
|
||||||
|
|
||||||
struct Run: AsyncParsableCommand {
|
struct Run: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Run a VM")
|
static var configuration = CommandConfiguration(abstract: "Run a VM")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name")
|
||||||
var name: String
|
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
|
@MainActor
|
||||||
func run() async throws {
|
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)
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
vm = try VM(vmDir: vmDir)
|
vm = try VM(vmDir: vmDir)
|
||||||
|
|
||||||
await withThrowingTaskGroup(of: Void.self) { group in
|
await withThrowingTaskGroup(of: Void.self) { group in
|
||||||
|
if vnc {
|
||||||
|
group.addTask(operation: {
|
||||||
|
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)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
group.addTask {
|
group.addTask {
|
||||||
do {
|
do {
|
||||||
try await vm!.run(recovery)
|
try await vm!.run(recovery)
|
||||||
|
|
||||||
Foundation.exit(0)
|
Foundation.exit(0)
|
||||||
} catch {
|
} catch {
|
||||||
print(error)
|
if error.localizedDescription.contains("Failed to lock auxiliary storage.") {
|
||||||
|
print("Virtual machine \"\(name)\" is already running!")
|
||||||
|
} else {
|
||||||
|
print(error)
|
||||||
|
}
|
||||||
|
|
||||||
Foundation.exit(1)
|
Foundation.exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if noGraphics {
|
if noGraphics || vnc {
|
||||||
dispatchMain()
|
dispatchMain()
|
||||||
} else {
|
} else {
|
||||||
runUI()
|
runUI()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import ArgumentParser
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct Set: AsyncParsableCommand {
|
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")
|
@Argument(help: "VM name")
|
||||||
var name: String
|
var name: String
|
||||||
@@ -13,7 +13,7 @@ struct Set: AsyncParsableCommand {
|
|||||||
@Option(help: "VM memory size in megabytes")
|
@Option(help: "VM memory size in megabytes")
|
||||||
var memory: UInt16?
|
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?
|
var display: VMDisplayConfig?
|
||||||
|
|
||||||
@Option(help: .hidden)
|
@Option(help: .hidden)
|
||||||
@@ -39,9 +39,6 @@ struct Set: AsyncParsableCommand {
|
|||||||
if (display.height > 0) {
|
if (display.height > 0) {
|
||||||
vmConfig.display.height = display.height
|
vmConfig.display.height = display.height
|
||||||
}
|
}
|
||||||
if (display.dpi > 0) {
|
|
||||||
vmConfig.display.dpi = display.dpi
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try vmConfig.save(toURL: vmDir.configURL)
|
try vmConfig.save(toURL: vmDir.configURL)
|
||||||
@@ -66,8 +63,7 @@ extension VMDisplayConfig: ExpressibleByArgument {
|
|||||||
}
|
}
|
||||||
self = VMDisplayConfig(
|
self = VMDisplayConfig(
|
||||||
width: parts[safe: 0] ?? 0,
|
width: parts[safe: 0] ?? 0,
|
||||||
height: parts[safe: 1] ?? 0,
|
height: parts[safe: 1] ?? 0
|
||||||
dpi: parts[safe: 2] ?? 0
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -8,6 +8,10 @@ struct OCIManifest: Codable, Equatable {
|
|||||||
var mediaType: String = ociManifestMediaType
|
var mediaType: String = ociManifestMediaType
|
||||||
var config: OCIManifestConfig
|
var config: OCIManifestConfig
|
||||||
var layers: [OCIManifestLayer] = Array()
|
var layers: [OCIManifestLayer] = Array()
|
||||||
|
|
||||||
|
func digest() throws -> String {
|
||||||
|
try Digest.hash(JSONEncoder().encode(self))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct OCIManifestConfig: Codable, Equatable {
|
struct OCIManifestConfig: Codable, Equatable {
|
||||||
|
|||||||
@@ -72,27 +72,40 @@ struct TokenResponse: Decodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fileprivate let httpClient = HTTPClient(eventLoopGroupProvider: .createNew)
|
|
||||||
|
|
||||||
class Registry {
|
class Registry {
|
||||||
var baseURL: URL
|
private let httpClient = HTTPClient(eventLoopGroupProvider: .createNew)
|
||||||
var namespace: String
|
|
||||||
|
deinit {
|
||||||
|
try! httpClient.syncShutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseURL: URL
|
||||||
|
let namespace: String
|
||||||
|
let credentialsProvider: CredentialsProvider
|
||||||
|
|
||||||
var currentAuthToken: TokenResponse? = nil
|
var currentAuthToken: TokenResponse? = nil
|
||||||
|
|
||||||
init(urlComponents: URLComponents, namespace: String) throws {
|
init(urlComponents: URLComponents,
|
||||||
|
namespace: String,
|
||||||
|
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider()
|
||||||
|
) throws {
|
||||||
baseURL = urlComponents.url!
|
baseURL = urlComponents.url!
|
||||||
self.namespace = namespace
|
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()
|
var baseURLComponents = URLComponents()
|
||||||
|
|
||||||
baseURLComponents.scheme = "https"
|
baseURLComponents.scheme = "https"
|
||||||
baseURLComponents.host = host
|
baseURLComponents.host = host
|
||||||
baseURLComponents.path = "/v2/"
|
baseURLComponents.path = "/v2/"
|
||||||
|
|
||||||
try self.init(urlComponents: baseURLComponents, namespace: namespace)
|
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProvider: credentialsProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ping() async throws {
|
func ping() async throws {
|
||||||
@@ -281,7 +294,7 @@ class Registry {
|
|||||||
|
|
||||||
var headers: Dictionary<String, String> = Dictionary()
|
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()
|
let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString()
|
||||||
headers["Authorization"] = "Basic \(encodedCredentials!)"
|
headers["Authorization"] = "Basic \(encodedCredentials!)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,57 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Parsing
|
import Parsing
|
||||||
|
|
||||||
struct Tail {
|
struct Reference: Comparable, Hashable, CustomStringConvertible {
|
||||||
enum TailType {
|
enum ReferenceType: Comparable {
|
||||||
case Tag
|
case Tag
|
||||||
case Digest
|
case Digest
|
||||||
}
|
}
|
||||||
|
|
||||||
var type: TailType
|
let type: ReferenceType
|
||||||
var value: String
|
let value: String
|
||||||
}
|
|
||||||
|
|
||||||
struct RemoteName: Comparable, CustomStringConvertible {
|
var fullyQualified: String {
|
||||||
var host: String
|
|
||||||
var namespace: String
|
|
||||||
var reference: String = "latest"
|
|
||||||
var fullyQualifiedReference: String {
|
|
||||||
get {
|
get {
|
||||||
if reference.starts(with: "sha256:") {
|
switch type {
|
||||||
return "@" + reference
|
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.host = host
|
||||||
self.namespace = namespace
|
self.namespace = namespace
|
||||||
self.reference = reference
|
self.reference = reference
|
||||||
@@ -58,13 +84,13 @@ struct RemoteName: Comparable, CustomStringConvertible {
|
|||||||
Parse {
|
Parse {
|
||||||
":"
|
":"
|
||||||
csNormal.map {
|
csNormal.map {
|
||||||
Tail(type: .Tag, value: String($0))
|
Reference(tag: String($0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Parse {
|
Parse {
|
||||||
"@sha256:"
|
"@sha256:"
|
||||||
csHex.map {
|
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)
|
host = String(result.0)
|
||||||
namespace = String(result.1)
|
namespace = String(result.1)
|
||||||
if let tail = result.2 {
|
reference = result.2 ?? Reference(tag: "latest")
|
||||||
reference = tail.value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func <(lhs: RemoteName, rhs: RemoteName) -> Bool {
|
static func <(lhs: RemoteName, rhs: RemoteName) -> Bool {
|
||||||
@@ -92,7 +116,7 @@ struct RemoteName: Comparable, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
"\(host)/\(namespace)\(fullyQualifiedReference)"
|
"\(host)/\(namespace)\(reference.fullyQualified)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,15 @@ struct Root: AsyncParsableCommand {
|
|||||||
])
|
])
|
||||||
|
|
||||||
public static func main() async throws {
|
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 task = withUnsafeCurrentTask { $0 }!
|
||||||
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
|
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
|
||||||
sigintSrc.setEventHandler {
|
sigintSrc.setEventHandler {
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
Darwin.exit(1)
|
||||||
}
|
}
|
||||||
sigintSrc.activate()
|
sigintSrc.activate()
|
||||||
|
|
||||||
|
|||||||
+17
-6
@@ -172,13 +172,24 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
// Display
|
// Display
|
||||||
let graphicsDeviceConfiguration = VZMacGraphicsDeviceConfiguration()
|
let graphicsDeviceConfiguration = VZMacGraphicsDeviceConfiguration()
|
||||||
graphicsDeviceConfiguration.displays = [
|
if let hostMainScreen = NSScreen.main {
|
||||||
VZMacGraphicsDisplayConfiguration(
|
let vmScreenSize = NSSize(
|
||||||
widthInPixels: vmConfig.display.width,
|
width: vmConfig.display.width,
|
||||||
heightInPixels: vmConfig.display.height,
|
height: vmConfig.display.height
|
||||||
pixelsPerInch: vmConfig.display.dpi
|
|
||||||
)
|
)
|
||||||
]
|
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]
|
configuration.graphicsDevices = [graphicsDeviceConfiguration]
|
||||||
|
|
||||||
// Audio
|
// Audio
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ enum CodingKeys: String, CodingKey {
|
|||||||
struct VMDisplayConfig: Codable {
|
struct VMDisplayConfig: Codable {
|
||||||
var width: Int = 1024
|
var width: Int = 1024
|
||||||
var height: Int = 768
|
var height: Int = 768
|
||||||
var dpi: Int = 72
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VMConfig: Codable {
|
struct VMConfig: Codable {
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ extension VMDirectory {
|
|||||||
try nvram.close()
|
try nvram.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func pushToRegistry(registry: Registry, references: [String]) async throws {
|
func pushToRegistry(registry: Registry, references: [String]) async throws -> RemoteName {
|
||||||
var layers = Array<OCIManifestLayer>()
|
var layers = Array<OCIManifestLayer>()
|
||||||
|
|
||||||
// Read VM's config and push it as blob
|
// Read VM's config and push it as blob
|
||||||
@@ -155,6 +155,9 @@ extension VMDirectory {
|
|||||||
|
|
||||||
_ = try await registry.pushManifest(reference: reference, manifest: manifest)
|
_ = 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ struct VMDirectory {
|
|||||||
baseURL.lastPathComponent
|
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 {
|
var initialized: Bool {
|
||||||
FileManager.default.fileExists(atPath: configURL.path) &&
|
FileManager.default.fileExists(atPath: configURL.path) &&
|
||||||
FileManager.default.fileExists(atPath: diskURL.path) &&
|
FileManager.default.fileExists(atPath: diskURL.path) &&
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ class VMStorageLocal {
|
|||||||
return vmDir
|
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 {
|
func delete(_ name: String) throws {
|
||||||
try FileManager.default.removeItem(at: vmURL(name))
|
try FileManager.default.removeItem(at: vmURL(name))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ class VMStorageOCI {
|
|||||||
return vmDir
|
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 {
|
func delete(_ name: RemoteName) throws {
|
||||||
try FileManager.default.removeItem(at: vmURL(name))
|
try FileManager.default.removeItem(at: vmURL(name))
|
||||||
}
|
}
|
||||||
@@ -64,35 +75,43 @@ class VMStorageOCI {
|
|||||||
func pull(_ name: RemoteName, registry: Registry) async throws {
|
func pull(_ name: RemoteName, registry: Registry) async throws {
|
||||||
defaultLogger.appendNewLine("pulling manifest...")
|
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) {
|
if !exists(digestName) {
|
||||||
let vmDir = try create(digestName)
|
let tmpVMDir = try VMDirectory.temporary()
|
||||||
try await vmDir.pullFromRegistry(registry: registry, manifest: manifest)
|
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 {
|
} 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 name != digestName {
|
||||||
if digestName != name {
|
|
||||||
// Overwrite the old symbolic link
|
// Overwrite the old symbolic link
|
||||||
if FileManager.default.fileExists(atPath: vmURL(name).path) {
|
try link(from: digestName, to: name)
|
||||||
try FileManager.default.removeItem(at: vmURL(name))
|
|
||||||
}
|
|
||||||
|
|
||||||
try FileManager.default.createSymbolicLink(at: vmURL(name), withDestinationURL: vmURL(digestName))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
extension URL {
|
||||||
func appendingRemoteName(_ name: RemoteName) -> URL {
|
func appendingRemoteName(_ name: RemoteName) -> URL {
|
||||||
var result: URL = self
|
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))
|
result = result.appendingPathComponent(String(pathComponent))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import XCTest
|
|||||||
|
|
||||||
final class RemoteNameTests: XCTestCase {
|
final class RemoteNameTests: XCTestCase {
|
||||||
func testTag() throws {
|
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"))
|
XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:latest"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testComplexTag() throws {
|
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"))
|
XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:1.2.3-RC-1"))
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@ final class RemoteNameTests: XCTestCase {
|
|||||||
let expectedRemoteName = RemoteName(
|
let expectedRemoteName = RemoteName(
|
||||||
host: "ghcr.io",
|
host: "ghcr.io",
|
||||||
namespace: "a/b",
|
namespace: "a/b",
|
||||||
reference: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
reference: Reference(digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
|
||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertEqual(expectedRemoteName,
|
XCTAssertEqual(expectedRemoteName,
|
||||||
|
|||||||
Reference in New Issue
Block a user