Skip to content

Commit 2e35b41

Browse files
authored
Implement self-update command (#81)
This also generalizes the HTTP response mocking framework to support mocking any request, not just toolchain downloads.
1 parent d698703 commit 2e35b41

File tree

13 files changed

+353
-110
lines changed

13 files changed

+353
-110
lines changed

Sources/LinuxPlatform/Linux.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,9 @@ public struct Linux: Platform {
131131
[]
132132
}
133133

134-
public func selfUpdate() async throws {}
134+
public func getExecutableName(forArch: String) -> String {
135+
"swiftly-\(forArch)-unknown-linux-gnu"
136+
}
135137

136138
public func currentToolchain() throws -> ToolchainVersion? { nil }
137139

Sources/Swiftly/Config.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ public struct Config: Codable, Equatable {
2323

2424
/// The CPU architecture of the platform. If omitted, assumed to be x86_64.
2525
public let architecture: String?
26+
27+
public func getArchitecture() -> String {
28+
self.architecture ?? "x86_64"
29+
}
2630
}
2731

2832
public var inUse: ToolchainVersion?

Sources/Swiftly/Install.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ struct Install: SwiftlyCommand {
126126
url += "\(snapshotString)-\(release.date)-a-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)"
127127
}
128128

129+
guard let url = URL(string: url) else {
130+
throw Error(message: "Invalid toolchain URL: \(url)")
131+
}
132+
129133
let animation = PercentProgressAnimation(
130134
stream: stdoutStream,
131135
header: "Downloading \(version)"
@@ -134,10 +138,9 @@ struct Install: SwiftlyCommand {
134138
var lastUpdate = Date()
135139

136140
do {
137-
try await httpClient.downloadToolchain(
138-
version,
141+
try await httpClient.downloadFile(
139142
url: url,
140-
to: tmpFile.path,
143+
to: tmpFile,
141144
reportProgress: { progress in
142145
let now = Date()
143146

Sources/Swiftly/SelfUpdate.swift

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,78 @@
11
import ArgumentParser
2+
import Foundation
3+
import TSCBasic
4+
import TSCUtility
5+
6+
import SwiftlyCore
27

38
internal struct SelfUpdate: SwiftlyCommand {
49
public static var configuration = CommandConfiguration(
510
abstract: "Update the version of swiftly itself."
611
)
712

13+
internal var httpClient = SwiftlyHTTPClient()
14+
15+
private enum CodingKeys: CodingKey {}
16+
817
internal mutating func run() async throws {
9-
print("updating swiftly")
10-
try await Swiftly.currentPlatform.selfUpdate()
18+
SwiftlyCore.print("Checking for swiftly updates...")
19+
20+
let release: SwiftlyGitHubRelease = try await self.httpClient.getFromGitHub(
21+
url: "https://api.github.com/repos/swift-server/swiftly/releases/latest"
22+
)
23+
24+
let version = try SwiftlyVersion(parsing: release.tag)
25+
26+
guard version > SwiftlyCore.version else {
27+
SwiftlyCore.print("Already up to date.")
28+
return
29+
}
30+
31+
SwiftlyCore.print("A new version is available: \(version)")
32+
33+
let config = try Config.load()
34+
let executableName = Swiftly.currentPlatform.getExecutableName(forArch: config.platform.getArchitecture())
35+
let urlString = "https://github.com/swift-server/swiftly/versions/latest/download/\(executableName)"
36+
guard let downloadURL = URL(string: urlString) else {
37+
throw Error(message: "Invalid download url: \(urlString)")
38+
}
39+
40+
let tmpFile = Swiftly.currentPlatform.getTempFilePath()
41+
FileManager.default.createFile(atPath: tmpFile.path, contents: nil)
42+
defer {
43+
try? FileManager.default.removeItem(at: tmpFile)
44+
}
45+
46+
let animation = PercentProgressAnimation(
47+
stream: stdoutStream,
48+
header: "Downloading swiftly \(version)"
49+
)
50+
do {
51+
try await self.httpClient.downloadFile(
52+
url: downloadURL,
53+
to: tmpFile,
54+
reportProgress: { progress in
55+
let downloadedMiB = Double(progress.receivedBytes) / (1024.0 * 1024.0)
56+
let totalMiB = Double(progress.totalBytes!) / (1024.0 * 1024.0)
57+
58+
animation.update(
59+
step: progress.receivedBytes,
60+
total: progress.totalBytes!,
61+
text: "Downloaded \(String(format: "%.1f", downloadedMiB)) MiB of \(String(format: "%.1f", totalMiB)) MiB"
62+
)
63+
}
64+
)
65+
} catch {
66+
animation.complete(success: false)
67+
throw error
68+
}
69+
animation.complete(success: true)
70+
71+
let swiftlyExecutable = Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent("swiftly", isDirectory: false)
72+
try FileManager.default.removeItem(at: swiftlyExecutable)
73+
try FileManager.default.moveItem(at: tmpFile, to: swiftlyExecutable)
74+
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: swiftlyExecutable.path)
75+
76+
SwiftlyCore.print("Successfully updated swiftly to \(version) (was \(SwiftlyCore.version))")
1177
}
1278
}

Sources/Swiftly/Swiftly.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@ public struct Swiftly: SwiftlyCommand {
1111
public static var configuration = CommandConfiguration(
1212
abstract: "A utility for installing and managing Swift toolchains.",
1313

14-
version: "0.1.0",
14+
version: String(describing: SwiftlyCore.version),
1515

1616
subcommands: [
1717
Install.self,
1818
Use.self,
1919
Uninstall.self,
2020
List.self,
2121
Update.self,
22+
SelfUpdate.self,
2223
]
2324
)
2425

Sources/SwiftlyCore/HTTPClient+GitHubAPI.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@ import _StringProcessing
22
import AsyncHTTPClient
33
import Foundation
44

5+
public struct SwiftlyGitHubRelease: Codable {
6+
public let tag: String
7+
}
8+
59
extension SwiftlyHTTPClient {
610
/// Get a JSON response from the GitHub REST API.
711
/// This will use the authorization token set, if any.
8-
private func getFromGitHub<T: Decodable>(url: String) async throws -> T {
12+
public func getFromGitHub<T: Decodable>(url: String) async throws -> T {
913
var headers: [String: String] = [:]
1014
if let token = self.githubToken ?? ProcessInfo.processInfo.environment["SWIFTLY_GITHUB_TOKEN"] {
1115
headers["Authorization"] = "Bearer \(token)"

Sources/SwiftlyCore/HTTPClient.swift

Lines changed: 59 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -5,91 +5,49 @@ import NIO
55
import NIOFoundationCompat
66
import NIOHTTP1
77

8-
/// Protocol describing the behavior for downloading a tooclhain.
9-
/// This is used to abstract over the underlying HTTP client to allow for mocking downloads in tests.
10-
public protocol ToolchainDownloader {
11-
func downloadToolchain(
12-
_ toolchain: ToolchainVersion,
13-
url: String,
14-
to destination: String,
15-
reportProgress: @escaping (SwiftlyHTTPClient.DownloadProgress) -> Void
16-
) async throws
8+
public protocol HTTPRequestExecutor {
9+
func execute(_ request: HTTPClientRequest, timeout: TimeAmount) async throws -> HTTPClientResponse
1710
}
1811

19-
/// The default implementation of a toolchain downloader.
20-
/// Downloads toolchains from swift.org.
21-
private struct HTTPToolchainDownloader: ToolchainDownloader {
22-
func downloadToolchain(
23-
_: ToolchainVersion,
24-
url: String,
25-
to destination: String,
26-
reportProgress: @escaping (SwiftlyHTTPClient.DownloadProgress) -> Void
27-
) async throws {
28-
let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: destination))
29-
defer {
30-
try? fileHandle.close()
31-
}
32-
33-
let request = SwiftlyHTTPClient.client.makeRequest(url: url)
34-
let response = try await SwiftlyHTTPClient.client.inner.execute(request, timeout: .seconds(30))
35-
36-
guard case response.status = HTTPResponseStatus.ok else {
37-
throw Error(message: "Received \(response.status) when trying to download \(url)")
38-
}
39-
40-
// Unknown download.swift.org paths redirect to a 404 page which then returns a 200 status.
41-
// As a heuristic for if we've hit the 404 page, we check to see if the content is HTML.
42-
guard !response.headers["Content-Type"].contains(where: { $0.contains("text/html") }) else {
43-
throw SwiftlyHTTPClient.DownloadNotFoundError(url: url)
44-
}
45-
46-
// if defined, the content-length headers announces the size of the body
47-
let expectedBytes = response.headers.first(name: "content-length").flatMap(Int.init)
48-
49-
var receivedBytes = 0
50-
for try await buffer in response.body {
51-
receivedBytes += buffer.readableBytes
52-
53-
try buffer.withUnsafeReadableBytes { bufferPtr in
54-
try fileHandle.write(contentsOf: bufferPtr)
55-
}
56-
reportProgress(SwiftlyHTTPClient.DownloadProgress(
57-
receivedBytes: receivedBytes,
58-
totalBytes: expectedBytes
59-
)
60-
)
61-
}
12+
/// An `HTTPRequestExecutor` backed by an `HTTPClient`.
13+
internal struct HTTPRequestExecutorImpl: HTTPRequestExecutor {
14+
fileprivate static let client = HTTPClientWrapper()
6215

63-
try fileHandle.synchronize()
16+
public func execute(_ request: HTTPClientRequest, timeout: TimeAmount) async throws -> HTTPClientResponse {
17+
try await Self.client.inner.execute(request, timeout: timeout)
6418
}
6519
}
6620

21+
private func makeRequest(url: String) -> HTTPClientRequest {
22+
var request = HTTPClientRequest(url: url)
23+
request.headers.add(name: "User-Agent", value: "swiftly/\(SwiftlyCore.version)")
24+
return request
25+
}
26+
6727
/// HTTPClient wrapper used for interfacing with various REST APIs and downloading things.
6828
public struct SwiftlyHTTPClient {
69-
fileprivate static let client = HTTPClientWrapper()
70-
7129
private struct Response {
7230
let status: HTTPResponseStatus
7331
let buffer: ByteBuffer
7432
}
7533

76-
private let downloader: ToolchainDownloader
34+
private let executor: HTTPRequestExecutor
7735

7836
/// The GitHub authentication token to use for any requests made to the GitHub API.
7937
public var githubToken: String?
8038

81-
public init(toolchainDownloader: ToolchainDownloader? = nil) {
82-
self.downloader = toolchainDownloader ?? HTTPToolchainDownloader()
39+
public init(executor: HTTPRequestExecutor? = nil) {
40+
self.executor = executor ?? HTTPRequestExecutorImpl()
8341
}
8442

8543
private func get(url: String, headers: [String: String]) async throws -> Response {
86-
var request = Self.client.makeRequest(url: url)
44+
var request = makeRequest(url: url)
8745

8846
for (k, v) in headers {
8947
request.headers.add(name: k, value: v)
9048
}
9149

92-
let response = try await Self.client.inner.execute(request, timeout: .seconds(30))
50+
let response = try await self.executor.execute(request, timeout: .seconds(30))
9351

9452
// if defined, the content-length headers announces the size of the body
9553
let expectedBytes = response.headers.first(name: "content-length").flatMap(Int.init) ?? 1024 * 1024
@@ -179,30 +137,53 @@ public struct SwiftlyHTTPClient {
179137
public let url: String
180138
}
181139

182-
public func downloadToolchain(
183-
_ toolchain: ToolchainVersion,
184-
url: String,
185-
to destination: String,
186-
reportProgress: @escaping (DownloadProgress) -> Void
187-
) async throws {
188-
try await self.downloader.downloadToolchain(
189-
toolchain,
190-
url: url,
191-
to: destination,
192-
reportProgress: reportProgress
193-
)
140+
public func downloadFile(url: URL, to destination: URL, reportProgress: @escaping (DownloadProgress) -> Void) async throws {
141+
let fileHandle = try FileHandle(forWritingTo: destination)
142+
defer {
143+
try? fileHandle.close()
144+
}
145+
146+
let request = makeRequest(url: url.absoluteString)
147+
let response = try await self.executor.execute(request, timeout: .seconds(30))
148+
149+
switch response.status {
150+
case .ok:
151+
break
152+
case .notFound:
153+
throw SwiftlyHTTPClient.DownloadNotFoundError(url: url.path)
154+
default:
155+
throw Error(message: "Received \(response.status) when trying to download \(url)")
156+
}
157+
158+
// if defined, the content-length headers announces the size of the body
159+
let expectedBytes = response.headers.first(name: "content-length").flatMap(Int.init)
160+
161+
var lastUpdate = Date()
162+
var receivedBytes = 0
163+
for try await buffer in response.body {
164+
receivedBytes += buffer.readableBytes
165+
166+
try buffer.withUnsafeReadableBytes { bufferPtr in
167+
try fileHandle.write(contentsOf: bufferPtr)
168+
}
169+
170+
let now = Date()
171+
if lastUpdate.distance(to: now) > 0.25 || receivedBytes == expectedBytes {
172+
lastUpdate = now
173+
reportProgress(SwiftlyHTTPClient.DownloadProgress(
174+
receivedBytes: receivedBytes,
175+
totalBytes: expectedBytes
176+
))
177+
}
178+
}
179+
180+
try fileHandle.synchronize()
194181
}
195182
}
196183

197184
private class HTTPClientWrapper {
198185
fileprivate let inner = HTTPClient(eventLoopGroupProvider: .singleton)
199186

200-
fileprivate func makeRequest(url: String) -> HTTPClientRequest {
201-
var request = HTTPClientRequest(url: url)
202-
request.headers.add(name: "User-Agent", value: "swiftly")
203-
return request
204-
}
205-
206187
deinit {
207188
try? self.inner.syncShutdown()
208189
}

Sources/SwiftlyCore/Platform.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,8 @@ public protocol Platform {
3333
/// This will likely have a default implementation.
3434
func listAvailableSnapshots(version: String?) async -> [Snapshot]
3535

36-
/// Update swiftly itself, if a new version has been released.
37-
/// This will likely have a default implementation.
38-
func selfUpdate() async throws
36+
/// Get the name of the release binary for this platform with the given CPU arch.
37+
func getExecutableName(forArch: String) -> String
3938

4039
/// Get a path pointing to a unique, temporary file.
4140
/// This does not need to actually create the file.

Sources/SwiftlyCore/SwiftlyCore.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import Foundation
22

3+
public let version = SwiftlyVersion(major: 0, minor: 1, patch: 0)
4+
35
/// A separate home directory to use for testing purposes. This overrides swiftly's default
46
/// home directory location logic.
57
public var mockedHomeDir: URL?

0 commit comments

Comments
 (0)