Compare commits

..
3 Commits
6 changed files with 118 additions and 33 deletions
+25
View File
@@ -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). **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 ## Virtual Machine Management
### Creating from scratch ### Creating from scratch
+32 -16
View File
@@ -24,41 +24,57 @@ struct Run: AsyncParsableCommand {
@Flag(help: ArgumentHelp( @Flag(help: ArgumentHelp(
"Use screen sharing instead of the built-in UI.", "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.")) discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
+ "Note that Remote Login option should be enabled inside the VM."))
var vnc: Bool = false var vnc: Bool = false
@Flag(help: ArgumentHelp(
"Use Virtualization.Framework's VNC server instead of the build-in UI.",
discussion: "Useful since this type of VNC is available in recovery mode and in macOS installation.\n"
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
var vncExperimental: Bool = false
@Flag var withSoftnet: Bool = false @Flag var withSoftnet: Bool = false
func validate() throws {
if vnc && vncExperimental {
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
}
}
@MainActor @MainActor
func run() async throws { func run() async throws {
let vmDir = try VMStorageLocal().open(name) let vmDir = try VMStorageLocal().open(name)
vm = try VM(vmDir: vmDir, withSoftnet: withSoftnet) vm = try VM(vmDir: vmDir, withSoftnet: withSoftnet)
var vncWrapper: VNCWrapper? let vncImpl: VNC? = try {
if vnc {
if vnc { let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
vncWrapper = VNCWrapper(virtualMachine: vm!.virtualMachine) return ScreenSharingVNC(vmConfig: vmConfig)
} } else if vncExperimental {
return FullFledgedVNC(virtualMachine: vm!.virtualMachine)
} else {
return nil
}
}()
let task = Task { let task = Task {
do { do {
if let vncWrapper = vncWrapper { if let vncImpl = vncImpl {
let port = try await vncWrapper.waitForPort() let vncURL = try await vncImpl.waitForURL()
let url = URL(string: "vnc://:\(vncWrapper.password)@127.0.0.1:\(port)")!
if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil { if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil {
print("VNC server is running at \(url)") print("VNC server is running at \(vncURL)")
} else { } else {
print("Opening \(url)...") print("Opening \(vncURL)...")
NSWorkspace.shared.open(url) NSWorkspace.shared.open(vncURL)
} }
} }
try await vm!.run(recovery) try await vm!.run(recovery)
if let vncWrapper = vncWrapper { if let vncImpl = vncImpl {
try vncWrapper.stop() try vncImpl.stop()
} }
Foundation.exit(0) Foundation.exit(0)
@@ -79,7 +95,7 @@ struct Run: AsyncParsableCommand {
} }
sigintSrc.activate() sigintSrc.activate()
if noGraphics || vnc { if noGraphics || vnc || vncExperimental {
dispatchMain() dispatchMain()
} else { } else {
runUI() runUI()
@@ -34,21 +34,34 @@ class KeychainCredentialsProvider: CredentialsProvider {
} }
func store(host: String, user: String, password: String) throws { func store(host: String, user: String, password: String) throws {
let passwordData = password.data(using: .utf8)
let attributes: [String: Any] = [kSecClass as String: kSecClassInternetPassword, let attributes: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrAccount as String: user, kSecAttrAccount as String: user,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS, kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host, kSecAttrServer as String: host,
kSecValueData as String: password, kSecValueData as String: passwordData,
kSecAttrLabel as String: "Tart Credentials", kSecAttrLabel as String: "Tart Credentials",
] ]
let status = SecItemAdd(attributes as CFDictionary, nil) let status = SecItemAdd(attributes as CFDictionary, nil)
switch status { switch status {
case errSecSuccess, errSecDuplicateItem: case errSecSuccess:
return 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: default:
throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)") 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
}
}
@@ -2,7 +2,7 @@ import Foundation
import Dynamic import Dynamic
import Virtualization import Virtualization
class VNCWrapper { class FullFledgedVNC: VNC {
let password: String let password: String
private let vnc: Dynamic private let vnc: Dynamic
@@ -15,6 +15,19 @@ class VNCWrapper {
vnc.start() vnc.start()
} }
func waitForURL() async throws -> URL {
while true {
// Port is 0 shortly after start(),
// but will be initialized later
if let port = vnc.port.asUInt16, port != 0 {
return URL(string: "vnc://:\(password)@127.0.0.1:\(port)")!
}
// Wait 50 ms.
try await Task.sleep(nanoseconds: 50_000_000)
}
}
func stop() throws { func stop() throws {
vnc.stop() vnc.stop()
} }
@@ -22,17 +35,4 @@ class VNCWrapper {
deinit { deinit {
try? stop() 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)
}
}
} }
+25
View File
@@ -0,0 +1,25 @@
import Foundation
import Dynamic
import Virtualization
class ScreenSharingVNC: VNC {
let vmConfig: VMConfig
init(vmConfig: VMConfig) {
self.vmConfig = vmConfig
}
func waitForURL() async throws -> URL {
let ip = try await IP.resolveIP(vmConfig, secondsToWait: 60)
if let ip = ip {
return URL(string: "vnc://\(ip)")!
}
throw IPNotFound()
}
func stop() throws {
// nothing to do
}
}
+6
View File
@@ -0,0 +1,6 @@
import Foundation
protocol VNC {
func waitForURL() async throws -> URL
func stop() throws
}