mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-12 17:05:31 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48cd4b47e4 | ||
|
|
c1dee4f9b2 | ||
|
|
116dc01f55 | ||
|
|
52abb7589c |
@@ -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
|
||||
|
||||
@@ -9,13 +9,13 @@ struct Login: AsyncParsableCommand {
|
||||
var host: String
|
||||
|
||||
@Option(help: "username")
|
||||
var username: String
|
||||
var username: String?
|
||||
|
||||
@Flag(help: "password-stdin")
|
||||
var passwordStdin: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
let usernameProvided = !username.isEmpty
|
||||
let usernameProvided = username != nil
|
||||
let passwordProvided = passwordStdin
|
||||
|
||||
if usernameProvided != passwordProvided {
|
||||
@@ -28,7 +28,7 @@ struct Login: AsyncParsableCommand {
|
||||
var user: String
|
||||
var password: String
|
||||
|
||||
if !username.isEmpty {
|
||||
if let username = username {
|
||||
user = username
|
||||
password = readLine()!
|
||||
} else {
|
||||
|
||||
@@ -24,41 +24,57 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
@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."))
|
||||
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
|
||||
|
||||
@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
|
||||
|
||||
func validate() throws {
|
||||
if vnc && vncExperimental {
|
||||
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
vm = try VM(vmDir: vmDir, withSoftnet: withSoftnet)
|
||||
|
||||
var vncWrapper: VNCWrapper?
|
||||
|
||||
if vnc {
|
||||
vncWrapper = VNCWrapper(virtualMachine: vm!.virtualMachine)
|
||||
}
|
||||
let vncImpl: VNC? = try {
|
||||
if vnc {
|
||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
return ScreenSharingVNC(vmConfig: vmConfig)
|
||||
} else if vncExperimental {
|
||||
return FullFledgedVNC(virtualMachine: vm!.virtualMachine)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
|
||||
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 let vncImpl = vncImpl {
|
||||
let vncURL = try await vncImpl.waitForURL()
|
||||
|
||||
if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil {
|
||||
print("VNC server is running at \(url)")
|
||||
print("VNC server is running at \(vncURL)")
|
||||
} else {
|
||||
print("Opening \(url)...")
|
||||
NSWorkspace.shared.open(url)
|
||||
print("Opening \(vncURL)...")
|
||||
NSWorkspace.shared.open(vncURL)
|
||||
}
|
||||
}
|
||||
|
||||
try await vm!.run(recovery)
|
||||
|
||||
if let vncWrapper = vncWrapper {
|
||||
try vncWrapper.stop()
|
||||
if let vncImpl = vncImpl {
|
||||
try vncImpl.stop()
|
||||
}
|
||||
|
||||
Foundation.exit(0)
|
||||
@@ -79,7 +95,7 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
sigintSrc.activate()
|
||||
|
||||
if noGraphics || vnc {
|
||||
if noGraphics || vnc || vncExperimental {
|
||||
dispatchMain()
|
||||
} else {
|
||||
runUI()
|
||||
|
||||
@@ -34,21 +34,34 @@ class KeychainCredentialsProvider: CredentialsProvider {
|
||||
}
|
||||
|
||||
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: password,
|
||||
kSecValueData as String: passwordData,
|
||||
kSecAttrLabel as String: "Tart Credentials",
|
||||
]
|
||||
|
||||
let status = SecItemAdd(attributes as CFDictionary, nil)
|
||||
|
||||
switch status {
|
||||
case errSecSuccess, errSecDuplicateItem:
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ extension HTTPClientResponse.Body {
|
||||
}
|
||||
}
|
||||
|
||||
struct TokenResponse: Decodable {
|
||||
struct TokenResponse: Decodable, Authentication {
|
||||
let defaultIssuedAt = Date()
|
||||
let defaultExpiresIn = 60
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +85,7 @@ class Registry {
|
||||
let namespace: String
|
||||
let credentialsProvider: CredentialsProvider
|
||||
|
||||
var currentAuthToken: TokenResponse? = nil
|
||||
var currentAuthToken: Authentication? = nil
|
||||
|
||||
init(urlComponents: URLComponents,
|
||||
namespace: String,
|
||||
@@ -245,7 +247,7 @@ class Registry {
|
||||
}
|
||||
|
||||
// Invalidate token if it has expired
|
||||
if currentAuthToken?.isValid == false {
|
||||
if currentAuthToken?.isValid() == false {
|
||||
currentAuthToken = nil
|
||||
}
|
||||
|
||||
@@ -266,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")
|
||||
@@ -316,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)
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
import Dynamic
|
||||
import Virtualization
|
||||
|
||||
class VNCWrapper {
|
||||
class FullFledgedVNC: VNC {
|
||||
let password: String
|
||||
private let vnc: Dynamic
|
||||
|
||||
@@ -15,6 +15,19 @@ class VNCWrapper {
|
||||
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 {
|
||||
vnc.stop()
|
||||
}
|
||||
@@ -22,17 +35,4 @@ class VNCWrapper {
|
||||
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,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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
protocol VNC {
|
||||
func waitForURL() async throws -> URL
|
||||
func stop() throws
|
||||
}
|
||||
@@ -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