mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-02 04:01:12 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1dee4f9b2 | ||
|
|
116dc01f55 | ||
|
|
52abb7589c | ||
|
|
4386192161 | ||
|
|
85429cea0a | ||
|
|
384abcd0bd | ||
|
|
92529afa23 | ||
|
|
c25364b2d4 | ||
|
|
e12f95878e | ||
|
|
7efef28250 | ||
|
|
1e90752ded | ||
|
|
2020324ef5 | ||
|
|
b581db5be4 | ||
|
|
8ed2ce159f | ||
|
|
89217c23a2 | ||
|
|
0d633de04a | ||
|
|
f59ef2722d | ||
|
|
7759c72a76 | ||
|
|
4cc8a9925a | ||
|
|
b16bbf587a | ||
|
|
022317bc17 | ||
|
|
87733290e7 | ||
|
|
c14b46adc3 | ||
|
|
63329ef363 | ||
|
|
5446164a36 | ||
|
|
f3068b9055 | ||
|
|
afa6b7b46c |
+13
-5
@@ -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
|
||||
|
||||
+5
-3
@@ -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
|
||||
@@ -36,5 +36,7 @@ brews:
|
||||
homepage: https://github.com/cirruslabs/tart
|
||||
description: Run macOS VMs on Apple Silicon
|
||||
skip_upload: auto
|
||||
dependencies:
|
||||
- "cirruslabs/cli/softnet"
|
||||
custom_block: |
|
||||
depends_on :macos => :monterey
|
||||
|
||||
@@ -55,6 +55,31 @@ config from above will just work in Cirrus CI and your tasks will be executed in
|
||||
|
||||
**Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
|
||||
|
||||
### Retrieving artifacts from within Tart VMs
|
||||
|
||||
In many cases there is a need to retrieve particular files or a folder from within a Tart virtual machine.
|
||||
For example, the below `.cirrus.yml` configuration defines a single task that builds a `tart` binary and
|
||||
exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tasks/#artifacts-instruction):
|
||||
|
||||
```yaml
|
||||
task:
|
||||
name: Build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-monterey-xcode:latest
|
||||
build_script: swift build --product tart
|
||||
binary_artifacts:
|
||||
path: .build/debug/tart
|
||||
```
|
||||
|
||||
Running Cirrus CLI with `--artifacts-dir` will write defined `artifacts` to the provided local directory on the host:
|
||||
|
||||
```bash
|
||||
cirrus run --artifacts-dir artifacts
|
||||
```
|
||||
|
||||
Note that all retrieved artifacts will be prefixed with the associated task name and `artifacts` instruction name.
|
||||
For the example above, `tart` binary will be saved to `$PWD/artifacts/Build/binary/.build/debug/tart`.
|
||||
|
||||
## Virtual Machine Management
|
||||
|
||||
### Creating from scratch
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8dd6af1a08bbcdc4faf0ff53601b38136c90231e11bd81bc8cd477d6f1c7d3f2
|
||||
size 209404
|
||||
oid sha256:1fe96aed7a965b075300f092a3ca76e09053eb7cf2f3125c3a819098a8bc4b31
|
||||
size 123360
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Virtualization
|
||||
|
||||
struct ARPCommandFailedError: Error, CustomStringConvertible {
|
||||
var terminationReason: Process.TerminationReason
|
||||
var terminationStatus: Int32
|
||||
|
||||
var description: String {
|
||||
var reason: String
|
||||
|
||||
switch terminationReason {
|
||||
case .exit:
|
||||
reason = "exit code \(terminationStatus)"
|
||||
case .uncaughtSignal:
|
||||
reason = "uncaught signal"
|
||||
default:
|
||||
reason = "unknown reason"
|
||||
}
|
||||
|
||||
return "arp command failed: \(reason)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible {
|
||||
var explanation: String
|
||||
|
||||
var description: String {
|
||||
"arp command yielded invalid output: \(explanation)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ARPCacheInternalError: Error, CustomStringConvertible {
|
||||
var explanation: String
|
||||
|
||||
var description: String {
|
||||
"ARPCache internal error: \(explanation)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ARPCache {
|
||||
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
|
||||
let process = Process.init()
|
||||
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
|
||||
process.arguments = ["-an"]
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||
throw ARPCommandFailedError(
|
||||
terminationReason: process.terminationReason,
|
||||
terminationStatus: process.terminationStatus)
|
||||
}
|
||||
|
||||
guard let rawLines = try pipe.fileHandleForReading.readToEnd() else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
||||
}
|
||||
let lines = String(decoding: rawLines, as: UTF8.self)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: "\n")
|
||||
|
||||
// Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
|
||||
let regex = try NSRegularExpression(pattern: #"^.* \((?<ip>.*)\) at (?<mac>.*) on (?<interface>.*) .*$"#)
|
||||
|
||||
for line in lines {
|
||||
let nsLineRange = NSRange(line.startIndex..<line.endIndex, in: line)
|
||||
|
||||
guard let match = regex.firstMatch(in: line, range: nsLineRange) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "unparseable entry \"\(line)\"")
|
||||
}
|
||||
|
||||
let rawIP = try match.getCaptureGroup(name: "ip", for: line)
|
||||
guard let ip = IPv4Address(rawIP) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse IPv4 address \(rawIP)")
|
||||
}
|
||||
|
||||
let rawMAC = try match.getCaptureGroup(name: "mac", for: line)
|
||||
if rawMAC == "(incomplete)" {
|
||||
continue
|
||||
}
|
||||
guard let mac = MACAddress(fromString: rawMAC) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse MAC address \(rawMAC)")
|
||||
}
|
||||
|
||||
let interface = try match.getCaptureGroup(name: "interface", for: line)
|
||||
if bridgeOnly && !interface.starts(with: "bridge") {
|
||||
continue
|
||||
}
|
||||
|
||||
if macAddress == mac {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension NSTextCheckingResult {
|
||||
func getCaptureGroup(name: String, for string: String) throws -> String {
|
||||
let nsRange = self.range(withName: name)
|
||||
|
||||
if nsRange.location == NSNotFound {
|
||||
throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)")
|
||||
}
|
||||
|
||||
guard let range = Range.init(nsRange, in: string) else {
|
||||
throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range")
|
||||
}
|
||||
|
||||
return String(string[range])
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -38,7 +38,7 @@ struct IP: AsyncParsableCommand {
|
||||
let vmMacAddress = MACAddress(fromString: config.macAddress.string)!
|
||||
|
||||
repeat {
|
||||
if let ip = try ARPCache.ResolveMACAddress(macAddress: vmMacAddress) {
|
||||
if let ip = try Leases().resolveMACAddress(macAddress: vmMacAddress) {
|
||||
return ip
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,46 @@ struct Login: AsyncParsableCommand {
|
||||
@Argument(help: "host")
|
||||
var host: String
|
||||
|
||||
@Option(help: "username")
|
||||
var username: String?
|
||||
|
||||
@Flag(help: "password-stdin")
|
||||
var passwordStdin: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
let usernameProvided = username != nil
|
||||
let passwordProvided = passwordStdin
|
||||
|
||||
if usernameProvided != passwordProvided {
|
||||
throw ValidationError("both --username and --password-stdin are required")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let (user, password) = try Credentials.retrieveStdin()
|
||||
var user: String
|
||||
var password: String
|
||||
|
||||
try Credentials.store(host: host, user: user, password: password)
|
||||
if let username = username {
|
||||
user = username
|
||||
password = readLine()!
|
||||
} else {
|
||||
(user, password) = try StdinCredentials.retrieve()
|
||||
}
|
||||
let credentialsProvider = DictionaryCredentialsProvider([
|
||||
host: (user, 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 +57,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)")
|
||||
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: ",") + "}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,51 +27,63 @@ struct Run: AsyncParsableCommand {
|
||||
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
|
||||
|
||||
@Flag var withSoftnet: Bool = false
|
||||
|
||||
@MainActor
|
||||
func run() async throws {
|
||||
if recovery && vnc {
|
||||
print("You can't run in recovery and use VNC!")
|
||||
Foundation.exit(1)
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
vm = try VM(vmDir: vmDir)
|
||||
vm = try VM(vmDir: vmDir, withSoftnet: withSoftnet)
|
||||
|
||||
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)")
|
||||
var vncWrapper: VNCWrapper?
|
||||
|
||||
if vnc {
|
||||
vncWrapper = VNCWrapper(virtualMachine: vm!.virtualMachine)
|
||||
}
|
||||
|
||||
let task = Task {
|
||||
do {
|
||||
if let vncWrapper = vncWrapper {
|
||||
let port = try await vncWrapper.waitForPort()
|
||||
|
||||
let url = URL(string: "vnc://:\(vncWrapper.password)@127.0.0.1:\(port)")!
|
||||
|
||||
if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil {
|
||||
print("VNC server is running at \(url)")
|
||||
} else {
|
||||
print("Opening \(url)...")
|
||||
NSWorkspace.shared.open(url)
|
||||
} catch {
|
||||
print("Failed to get an IP for screen sharing: \(error)")
|
||||
}
|
||||
})
|
||||
}
|
||||
group.addTask {
|
||||
do {
|
||||
try await vm!.run(recovery)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if noGraphics || vnc {
|
||||
dispatchMain()
|
||||
} else {
|
||||
runUI()
|
||||
try await vm!.run(recovery)
|
||||
|
||||
if let vncWrapper = vncWrapper {
|
||||
try vncWrapper.stop()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
|
||||
sigintSrc.setEventHandler {
|
||||
task.cancel()
|
||||
}
|
||||
sigintSrc.activate()
|
||||
|
||||
if noGraphics || vnc {
|
||||
dispatchMain()
|
||||
} else {
|
||||
runUI()
|
||||
}
|
||||
}
|
||||
|
||||
private func runUI() {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,16 @@ struct Config {
|
||||
.appendingPathComponent(".tart", isDirectory: true)
|
||||
|
||||
public static let tartCacheDir: URL = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
|
||||
|
||||
static func jsonEncoder() -> JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
|
||||
return encoder
|
||||
}
|
||||
|
||||
static func jsonDecoder() -> JSONDecoder {
|
||||
JSONDecoder()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,67 @@
|
||||
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 passwordData = password.data(using: .utf8)
|
||||
let attributes: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
||||
kSecAttrAccount as String: user,
|
||||
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
|
||||
kSecAttrServer as String: host,
|
||||
kSecValueData as String: passwordData,
|
||||
kSecAttrLabel as String: "Tart Credentials",
|
||||
]
|
||||
|
||||
let status = SecItemAdd(attributes as CFDictionary, nil)
|
||||
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
return
|
||||
case errSecDuplicateItem:
|
||||
let status = SecItemUpdate(attributes as CFDictionary,
|
||||
[kSecValueData as String : passwordData] as CFDictionary)
|
||||
if status != errSecSuccess {
|
||||
throw CredentialsProviderError.Failed(message: "Keychain failed to update item: \(status.explanation())")
|
||||
}
|
||||
default:
|
||||
throw CredentialsProviderError.Failed(message: "Keychain failed to add item: \(status.explanation())")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension OSStatus {
|
||||
func explanation() -> CFString {
|
||||
SecCopyErrorMessageString(self, nil) ?? "Unknown status code \(self)." as CFString
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,32 @@
|
||||
import Network
|
||||
|
||||
struct Lease {
|
||||
var mac: MACAddress
|
||||
var ip: IPv4Address
|
||||
|
||||
init?(fromRawLease: [String : String]) {
|
||||
// Retrieve the required fields
|
||||
guard let hwAddress = fromRawLease["hw_address"] else { return nil }
|
||||
guard let ipAddress = fromRawLease["ip_address"] else { return nil }
|
||||
|
||||
// Parse MAC address
|
||||
let hwAddressSplits = hwAddress.split(separator: ",")
|
||||
if hwAddressSplits.count != 2 {
|
||||
return nil
|
||||
}
|
||||
if let hwAddressProto = Int(hwAddressSplits[0]), hwAddressProto != ARPHRD_ETHER {
|
||||
return nil
|
||||
}
|
||||
guard let mac = MACAddress(fromString: String(hwAddressSplits[1])) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse IP address
|
||||
guard let ip = IPv4Address(ipAddress) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.ip = ip
|
||||
self.mac = mac
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
enum LeasesError: Error {
|
||||
case UnexpectedFormat(name: String = "unexpected DHCPD leases file format", message: String, line: Int)
|
||||
case Truncated(name: String = "truncated DHCPD leases file")
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
|
||||
case .UnexpectedFormat(name: let name, message: let message, line: let line):
|
||||
return "\(name) on line \(line): \(message)"
|
||||
case .Truncated(name: let name):
|
||||
return "\(name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Leases {
|
||||
private let leases: [MACAddress : Lease]
|
||||
|
||||
convenience init() throws {
|
||||
try self.init(URL(fileURLWithPath: "/var/db/dhcpd_leases"))
|
||||
}
|
||||
|
||||
convenience init(_ fromURL: URL) throws {
|
||||
let fileContents = try String(contentsOf: fromURL, encoding: .utf8)
|
||||
|
||||
try self.init(fileContents)
|
||||
}
|
||||
|
||||
init(_ fromString: String) throws {
|
||||
var leases: [MACAddress : Lease] = Dictionary()
|
||||
|
||||
for lease in try Self.retrieveRawLeases(fromString).compactMap({ Lease(fromRawLease: $0) }) {
|
||||
leases[lease.mac] = lease
|
||||
}
|
||||
|
||||
self.leases = leases
|
||||
}
|
||||
|
||||
/// Parse leases from the host cache similarly to the PLCache_read() function found in Apple's Open Source releases.
|
||||
///
|
||||
/// [1]: https://github.com/apple-opensource/bootp/blob/master/bootplib/NICache.c#L285-L391
|
||||
private static func retrieveRawLeases(_ dhcpdLeasesContents: String) throws -> [[String : String]] {
|
||||
var rawLeases: [[String : String]] = Array()
|
||||
|
||||
enum State {
|
||||
case Nowhere
|
||||
case Start
|
||||
case Body
|
||||
case End
|
||||
}
|
||||
var state = State.Nowhere
|
||||
|
||||
var currentRawLease: [String : String] = Dictionary()
|
||||
|
||||
for (lineNumber, line) in dhcpdLeasesContents.split(separator: "\n").enumerated().map({ ($0 + 1, $1) }) {
|
||||
if line == "{" {
|
||||
// Handle lease block start
|
||||
if state != .Nowhere && state != .End {
|
||||
throw LeasesError.UnexpectedFormat(message: "unexpected lease block start ({)", line: lineNumber)
|
||||
}
|
||||
|
||||
state = .Start
|
||||
} else if line == "}" {
|
||||
// Handle lease block end
|
||||
if state != .Body {
|
||||
throw LeasesError.UnexpectedFormat(message: "unexpected lease block end (})", line: lineNumber)
|
||||
}
|
||||
|
||||
rawLeases.append(currentRawLease)
|
||||
currentRawLease = Dictionary()
|
||||
|
||||
state = .End
|
||||
} else {
|
||||
// Handle lease block contents
|
||||
let lineWithoutTabs = String(line.drop { $0 == " " || $0 == "\t"})
|
||||
|
||||
if lineWithoutTabs.isEmpty {
|
||||
continue
|
||||
}
|
||||
|
||||
let splits = lineWithoutTabs.split(separator: "=", maxSplits: 1)
|
||||
if splits.count != 2 {
|
||||
throw LeasesError.UnexpectedFormat(message: "key-value pair with only a key", line: lineNumber)
|
||||
}
|
||||
let (key, value) = (String(splits[0]), String(splits[1]))
|
||||
|
||||
currentRawLease[key] = value
|
||||
|
||||
state = .Body
|
||||
}
|
||||
}
|
||||
|
||||
if state == .Start || state == .Body {
|
||||
throw LeasesError.Truncated()
|
||||
}
|
||||
|
||||
return rawLeases
|
||||
}
|
||||
|
||||
func resolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
||||
leases[macAddress]?.ip
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct MACAddress: Equatable, CustomStringConvertible {
|
||||
struct MACAddress: Equatable, Hashable, CustomStringConvertible {
|
||||
var mac: [UInt8] = Array(repeating: 0, count: 6)
|
||||
|
||||
init?(fromString: String) {
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
protocol Authentication {
|
||||
func header() -> (String, String)
|
||||
func isValid() -> Bool
|
||||
}
|
||||
|
||||
struct BasicAuthentication: Authentication {
|
||||
let user: String
|
||||
let password: String
|
||||
|
||||
func header() -> (String, String) {
|
||||
let creds = Data("\(user):\(password)".utf8).base64EncodedString()
|
||||
|
||||
return ("Authorization", "Basic \(creds)")
|
||||
}
|
||||
|
||||
func isValid() -> Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,55 @@ 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)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
init(fromJSON: Data) throws {
|
||||
self = try Config.jsonDecoder().decode(Self.self, from: fromJSON)
|
||||
}
|
||||
|
||||
func toJSON() throws -> Data {
|
||||
try Config.jsonEncoder().encode(self)
|
||||
}
|
||||
|
||||
func digest() throws -> String {
|
||||
try Digest.hash(toJSON())
|
||||
}
|
||||
|
||||
func uncompressedDiskSize() -> UInt64? {
|
||||
guard let value = annotations?[uncompressedDiskSizeAnnotation] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return UInt64(value)
|
||||
}
|
||||
}
|
||||
|
||||
struct OCIConfig: Codable {
|
||||
var architecture: String = "arm64"
|
||||
var os: String = "darwin"
|
||||
|
||||
func toJSON() throws -> Data {
|
||||
try Config.jsonEncoder().encode(self)
|
||||
}
|
||||
}
|
||||
|
||||
struct OCIManifestConfig: Codable, Equatable {
|
||||
|
||||
@@ -25,7 +25,7 @@ extension HTTPClientResponse.Body {
|
||||
}
|
||||
}
|
||||
|
||||
struct TokenResponse: Decodable {
|
||||
struct TokenResponse: Decodable, Authentication {
|
||||
let defaultIssuedAt = Date()
|
||||
let defaultExpiresIn = 60
|
||||
|
||||
@@ -34,7 +34,7 @@ struct TokenResponse: Decodable {
|
||||
var issuedAt: Date?
|
||||
|
||||
static func parse(fromData: Data) throws -> Self {
|
||||
let decoder = JSONDecoder()
|
||||
let decoder = Config.jsonDecoder()
|
||||
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
|
||||
@@ -65,10 +65,12 @@ struct TokenResponse: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
var isValid: Bool {
|
||||
get {
|
||||
Date() < tokenExpiresAt
|
||||
}
|
||||
func header() -> (String, String) {
|
||||
("Authorization", "Bearer \(token)")
|
||||
}
|
||||
|
||||
func isValid() -> Bool {
|
||||
Date() < tokenExpiresAt
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,24 +81,33 @@ class Registry {
|
||||
try! httpClient.syncShutdown()
|
||||
}
|
||||
|
||||
var baseURL: URL
|
||||
var namespace: String
|
||||
let baseURL: URL
|
||||
let namespace: String
|
||||
let credentialsProvider: CredentialsProvider
|
||||
|
||||
var currentAuthToken: TokenResponse? = nil
|
||||
var currentAuthToken: Authentication? = 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 {
|
||||
@@ -107,7 +118,7 @@ class Registry {
|
||||
}
|
||||
|
||||
func pushManifest(reference: String, manifest: OCIManifest) async throws -> String {
|
||||
let manifestJSON = try JSONEncoder().encode(manifest)
|
||||
let manifestJSON = try manifest.toJSON()
|
||||
|
||||
let response = try await endpointRequest(.PUT, "\(namespace)/manifests/\(reference)",
|
||||
headers: ["Content-Type": manifest.mediaType],
|
||||
@@ -130,7 +141,7 @@ class Registry {
|
||||
}
|
||||
|
||||
let manifestData = try await response.body.readResponse()
|
||||
let manifest = try JSONDecoder().decode(OCIManifest.self, from: manifestData)
|
||||
let manifest = try OCIManifest(fromJSON: manifestData)
|
||||
|
||||
return (manifest, manifestData)
|
||||
}
|
||||
@@ -175,8 +186,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
|
||||
@@ -191,6 +202,8 @@ class Registry {
|
||||
}
|
||||
|
||||
for try await part in response.body {
|
||||
try Task.checkCancellation()
|
||||
|
||||
try handler(part)
|
||||
}
|
||||
}
|
||||
@@ -234,7 +247,7 @@ class Registry {
|
||||
}
|
||||
|
||||
// Invalidate token if it has expired
|
||||
if currentAuthToken?.isValid == false {
|
||||
if currentAuthToken?.isValid() == false {
|
||||
currentAuthToken = nil
|
||||
}
|
||||
|
||||
@@ -255,6 +268,15 @@ class Registry {
|
||||
}
|
||||
|
||||
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
|
||||
|
||||
if wwwAuthenticate.scheme == "Basic" {
|
||||
if let (user, password) = try credentialsProvider.retrieve(host: baseURL.host!) {
|
||||
currentAuthToken = BasicAuthentication(user: user, password: password)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if wwwAuthenticate.scheme != "Bearer" {
|
||||
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's authentication scheme "
|
||||
+ "\"\(wwwAuthenticate.scheme)\" is unsupported, expected \"Bearer\" scheme")
|
||||
@@ -285,7 +307,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!)"
|
||||
}
|
||||
@@ -305,7 +327,8 @@ class Registry {
|
||||
var request = request
|
||||
|
||||
if let token = currentAuthToken {
|
||||
request.headers.add(name: "Authorization", value: "Bearer \(token.token)")
|
||||
let (name, value) = token.header()
|
||||
request.headers.add(name: name, value: value)
|
||||
}
|
||||
|
||||
return try await httpClient.execute(request, deadline: .distantFuture)
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
struct PassphraseGenerator: Sequence {
|
||||
func makeIterator() -> PassphraseIterator {
|
||||
PassphraseIterator()
|
||||
}
|
||||
}
|
||||
|
||||
struct PassphraseIterator: IteratorProtocol {
|
||||
mutating func next() -> String? {
|
||||
passphrases[Int(arc4random_uniform(UInt32(passphrases.count)))]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,10 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
|
||||
enum SoftnetError: Error {
|
||||
case InitializationFailed(why: String)
|
||||
}
|
||||
|
||||
class Softnet {
|
||||
private let process = Process()
|
||||
|
||||
let vmFD: Int32
|
||||
|
||||
init(vmMACAddress: String) throws {
|
||||
let binaryName = "softnet"
|
||||
|
||||
guard let executableURL = Self.resolveBinaryPath(binaryName) else {
|
||||
throw SoftnetError.InitializationFailed(why: "\(binaryName) not found in PATH")
|
||||
}
|
||||
|
||||
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
||||
|
||||
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
|
||||
if ret != 0 {
|
||||
throw SoftnetError.InitializationFailed(why: "socketpair() failed with exit code \(ret)")
|
||||
}
|
||||
|
||||
vmFD = fds[0]
|
||||
let softnetFD = fds[1]
|
||||
|
||||
try setSocketBuffers(vmFD, 1 * 1024 * 1024);
|
||||
try setSocketBuffers(softnetFD, 1 * 1024 * 1024);
|
||||
|
||||
process.executableURL = executableURL
|
||||
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress]
|
||||
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
|
||||
}
|
||||
|
||||
func run() throws {
|
||||
try process.run()
|
||||
}
|
||||
|
||||
func stop() throws {
|
||||
process.interrupt()
|
||||
process.waitUntilExit()
|
||||
}
|
||||
|
||||
private static func resolveBinaryPath(_ name: String) -> URL? {
|
||||
guard let path = ProcessInfo.processInfo.environment["PATH"] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
for pathComponent in path.split(separator: ":") {
|
||||
let url = URL(fileURLWithPath: String(pathComponent))
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws {
|
||||
var option_value = sizeBytes
|
||||
let option_len = socklen_t(MemoryLayout<Int>.size)
|
||||
|
||||
var ret = setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &option_value, option_len)
|
||||
if ret != 0 {
|
||||
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_RCVBUF) returned \(ret)")
|
||||
}
|
||||
|
||||
ret = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &option_value, option_len)
|
||||
if ret != 0 {
|
||||
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)")
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
-14
@@ -23,17 +23,24 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// VM's config
|
||||
var config: VMConfig
|
||||
|
||||
init(vmDir: VMDirectory) throws {
|
||||
var softnet: Softnet? = nil
|
||||
|
||||
init(vmDir: VMDirectory, withSoftnet: Bool = false) throws {
|
||||
let auxStorage = VZMacAuxiliaryStorage(contentsOf: vmDir.nvramURL)
|
||||
|
||||
name = vmDir.name
|
||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
|
||||
let configuration = try VM.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config)
|
||||
// Initialize the virtual machine and its configuration
|
||||
if withSoftnet {
|
||||
softnet = try Softnet(vmMACAddress: config.macAddress.string)
|
||||
}
|
||||
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config,
|
||||
softnet: softnet)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
super.init()
|
||||
|
||||
virtualMachine.delegate = self
|
||||
}
|
||||
|
||||
@@ -77,8 +84,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, withSoftnet: Bool = false) async throws {
|
||||
let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW();
|
||||
|
||||
// Load the restore image and try to get the requirements
|
||||
@@ -111,11 +127,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
try config.save(toURL: vmDir.configURL)
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
let configuration = try VM.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config)
|
||||
if withSoftnet {
|
||||
softnet = try Softnet(vmMACAddress: config.macAddress.string)
|
||||
}
|
||||
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config,
|
||||
softnet: softnet)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
super.init()
|
||||
|
||||
virtualMachine.delegate = self
|
||||
|
||||
// Run automated installation
|
||||
@@ -134,6 +154,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
func run(_ recovery: Bool) async throws {
|
||||
if let softnet = softnet {
|
||||
try softnet.run()
|
||||
}
|
||||
|
||||
try await virtualMachine.start(recovery)
|
||||
|
||||
await withTaskCancellationHandler(operation: {
|
||||
@@ -149,9 +173,18 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let softnet = softnet {
|
||||
try softnet.stop();
|
||||
}
|
||||
}
|
||||
|
||||
static func craftConfiguration(diskURL: URL, auxStorage: VZMacAuxiliaryStorage, vmConfig: VMConfig) throws -> VZVirtualMachineConfiguration {
|
||||
static func craftConfiguration(
|
||||
diskURL: URL,
|
||||
auxStorage: VZMacAuxiliaryStorage,
|
||||
vmConfig: VMConfig,
|
||||
softnet: Softnet? = nil
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
// Boot loader
|
||||
@@ -172,13 +205,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
|
||||
@@ -192,7 +236,13 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
// Networking
|
||||
let vio = VZVirtioNetworkDeviceConfiguration()
|
||||
vio.attachment = VZNATNetworkDeviceAttachment()
|
||||
|
||||
if let softnet = softnet {
|
||||
let fh = FileHandle.init(fileDescriptor: softnet.vmFD)
|
||||
vio.attachment = VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
|
||||
} else {
|
||||
vio.attachment = VZNATNetworkDeviceAttachment()
|
||||
}
|
||||
vio.macAddress = vmConfig.macAddress
|
||||
configuration.networkDevices = [vio]
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -60,12 +59,16 @@ struct VMConfig: Codable {
|
||||
memorySize = memorySizeMin
|
||||
}
|
||||
|
||||
init(fromData: Data) throws {
|
||||
self = try JSONDecoder().decode(VMConfig.self, from: fromData)
|
||||
init(fromJSON: Data) throws {
|
||||
self = try Config.jsonDecoder().decode(Self.self, from: fromJSON)
|
||||
}
|
||||
|
||||
init(fromURL: URL) throws {
|
||||
self = try Self(fromData: try Data(contentsOf: fromURL))
|
||||
self = try Self(fromJSON: try Data(contentsOf: fromURL))
|
||||
}
|
||||
|
||||
func toJSON() throws -> Data {
|
||||
try Config.jsonEncoder().encode(self)
|
||||
}
|
||||
|
||||
func save(toURL: URL) throws {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -137,16 +139,12 @@ extension VMDirectory {
|
||||
layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest))
|
||||
|
||||
// Craft a stub OCI config for Docker Hub compatibility
|
||||
struct OCIConfig: Codable {
|
||||
var architecture: String = "arm64"
|
||||
var os: String = "darwin"
|
||||
}
|
||||
|
||||
let ociConfigJSON = try JSONEncoder().encode(OCIConfig())
|
||||
let ociConfigJSON = try OCIConfig().toJSON()
|
||||
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 +153,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
import Dynamic
|
||||
import Virtualization
|
||||
|
||||
class VNCWrapper {
|
||||
let password: String
|
||||
private let vnc: Dynamic
|
||||
|
||||
init(virtualMachine: VZVirtualMachine) {
|
||||
password = Array(PassphraseGenerator().prefix(4)).joined(separator: "-")
|
||||
let securityConfiguration = Dynamic._VZVNCAuthenticationSecurityConfiguration(password: password)
|
||||
vnc = Dynamic._VZVNCServer(port: 0, queue: DispatchQueue.global(),
|
||||
securityConfiguration: securityConfiguration)
|
||||
vnc.virtualMachine = virtualMachine
|
||||
vnc.start()
|
||||
}
|
||||
|
||||
func stop() throws {
|
||||
vnc.stop()
|
||||
}
|
||||
|
||||
deinit {
|
||||
try? stop()
|
||||
}
|
||||
|
||||
func waitForPort() async throws -> UInt16 {
|
||||
while true {
|
||||
// Port is 0 shortly after start(),
|
||||
// but will be initialized later
|
||||
if let port = vnc.port.asUInt16, port != 0 {
|
||||
return port
|
||||
}
|
||||
|
||||
// Wait 50 ms.
|
||||
try await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import XCTest
|
||||
import Network
|
||||
@testable import tart
|
||||
|
||||
final class MACAddressResolverTests: XCTestCase {
|
||||
func testSingleEntry() throws {
|
||||
let leases = try Leases("""
|
||||
{
|
||||
ip_address=1.2.3.4
|
||||
hw_address=1,00:11:22:33:44:55
|
||||
}
|
||||
""")
|
||||
|
||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
}
|
||||
|
||||
func testMultipleEntries() throws {
|
||||
let leases = try Leases("""
|
||||
{
|
||||
ip_address=1.2.3.4
|
||||
hw_address=1,00:11:22:33:44:55
|
||||
}
|
||||
{
|
||||
ip_address=5.6.7.8
|
||||
hw_address=1,AA:BB:CC:DD:EE:FF
|
||||
}
|
||||
""")
|
||||
|
||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
XCTAssertEqual(IPv4Address("5.6.7.8"),
|
||||
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
|
||||
}
|
||||
}
|
||||
@@ -62,11 +62,7 @@ final class RegistryTests: XCTestCase {
|
||||
|
||||
func testPushPullManifest() async throws {
|
||||
// Craft a basic config
|
||||
struct OCIConfig: Codable {
|
||||
var architecture: String = "arm64"
|
||||
var os: String = "darwin"
|
||||
}
|
||||
let configData = try JSONEncoder().encode(OCIConfig())
|
||||
let configData = try OCIConfig().toJSON()
|
||||
let configDigest = try await registry.pushBlob(fromData: configData)
|
||||
|
||||
// Craft a basic layer
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,7 +11,7 @@ final class TokenResponseTests: XCTestCase {
|
||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(60)
|
||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||
|
||||
XCTAssertTrue(tokenResponse.isValid)
|
||||
XCTAssertTrue(tokenResponse.isValid())
|
||||
}
|
||||
|
||||
func testExpirationBasic() throws {
|
||||
@@ -23,9 +23,9 @@ final class TokenResponseTests: XCTestCase {
|
||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(2)
|
||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||
|
||||
XCTAssertTrue(tokenResponse.isValid)
|
||||
XCTAssertTrue(tokenResponse.isValid())
|
||||
_ = XCTWaiter.wait(for: [expectation(description: "Wait 3 seconds for the token to become invalid")], timeout: 2)
|
||||
XCTAssertFalse(tokenResponse.isValid)
|
||||
XCTAssertFalse(tokenResponse.isValid())
|
||||
}
|
||||
|
||||
func testExpirationWithIssuedAt() throws {
|
||||
@@ -33,6 +33,6 @@ final class TokenResponseTests: XCTestCase {
|
||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||
|
||||
XCTAssertEqual(Date(timeIntervalSince1970: 3600), tokenResponse.tokenExpiresAt)
|
||||
XCTAssertFalse(tokenResponse.isValid)
|
||||
XCTAssertFalse(tokenResponse.isValid())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user