Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement self-update command #81

Merged
merged 7 commits into from Dec 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 3 additions & 1 deletion Sources/LinuxPlatform/Linux.swift
Expand Up @@ -131,7 +131,9 @@ public struct Linux: Platform {
[]
}

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

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

Expand Down
4 changes: 4 additions & 0 deletions Sources/Swiftly/Config.swift
Expand Up @@ -23,6 +23,10 @@ public struct Config: Codable, Equatable {

/// The CPU architecture of the platform. If omitted, assumed to be x86_64.
public let architecture: String?

public func getArchitecture() -> String {
self.architecture ?? "x86_64"
}
}

public var inUse: ToolchainVersion?
Expand Down
9 changes: 6 additions & 3 deletions Sources/Swiftly/Install.swift
Expand Up @@ -126,6 +126,10 @@ struct Install: SwiftlyCommand {
url += "\(snapshotString)-\(release.date)-a-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)"
}

guard let url = URL(string: url) else {
throw Error(message: "Invalid toolchain URL: \(url)")
}

let animation = PercentProgressAnimation(
stream: stdoutStream,
header: "Downloading \(version)"
Expand All @@ -134,10 +138,9 @@ struct Install: SwiftlyCommand {
var lastUpdate = Date()

do {
try await httpClient.downloadToolchain(
version,
try await httpClient.downloadFile(
url: url,
to: tmpFile.path,
to: tmpFile,
reportProgress: { progress in
let now = Date()

Expand Down
70 changes: 68 additions & 2 deletions Sources/Swiftly/SelfUpdate.swift
@@ -1,12 +1,78 @@
import ArgumentParser
import Foundation
import TSCBasic
import TSCUtility

import SwiftlyCore

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

internal var httpClient = SwiftlyHTTPClient()

private enum CodingKeys: CodingKey {}

internal mutating func run() async throws {
print("updating swiftly")
try await Swiftly.currentPlatform.selfUpdate()
SwiftlyCore.print("Checking for swiftly updates...")

let release: SwiftlyGitHubRelease = try await self.httpClient.getFromGitHub(
url: "https://api.github.com/repos/swift-server/swiftly/releases/latest"
)

let version = try SwiftlyVersion(parsing: release.tag)

guard version > SwiftlyCore.version else {
SwiftlyCore.print("Already up to date.")
return
}

SwiftlyCore.print("A new version is available: \(version)")

let config = try Config.load()
let executableName = Swiftly.currentPlatform.getExecutableName(forArch: config.platform.getArchitecture())
let urlString = "https://github.com/swift-server/swiftly/versions/latest/download/\(executableName)"
guard let downloadURL = URL(string: urlString) else {
throw Error(message: "Invalid download url: \(urlString)")
}

let tmpFile = Swiftly.currentPlatform.getTempFilePath()
FileManager.default.createFile(atPath: tmpFile.path, contents: nil)
defer {
try? FileManager.default.removeItem(at: tmpFile)
}

let animation = PercentProgressAnimation(
stream: stdoutStream,
header: "Downloading swiftly \(version)"
)
do {
try await self.httpClient.downloadFile(
url: downloadURL,
to: tmpFile,
reportProgress: { progress in
let downloadedMiB = Double(progress.receivedBytes) / (1024.0 * 1024.0)
let totalMiB = Double(progress.totalBytes!) / (1024.0 * 1024.0)

animation.update(
step: progress.receivedBytes,
total: progress.totalBytes!,
text: "Downloaded \(String(format: "%.1f", downloadedMiB)) MiB of \(String(format: "%.1f", totalMiB)) MiB"
)
}
)
} catch {
animation.complete(success: false)
throw error
}
animation.complete(success: true)

let swiftlyExecutable = Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent("swiftly", isDirectory: false)
try FileManager.default.removeItem(at: swiftlyExecutable)
try FileManager.default.moveItem(at: tmpFile, to: swiftlyExecutable)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: swiftlyExecutable.path)

SwiftlyCore.print("Successfully updated swiftly to \(version) (was \(SwiftlyCore.version))")
}
}
3 changes: 2 additions & 1 deletion Sources/Swiftly/Swiftly.swift
Expand Up @@ -11,14 +11,15 @@ public struct Swiftly: SwiftlyCommand {
public static var configuration = CommandConfiguration(
abstract: "A utility for installing and managing Swift toolchains.",

version: "0.1.0",
version: String(describing: SwiftlyCore.version),

subcommands: [
Install.self,
Use.self,
Uninstall.self,
List.self,
Update.self,
SelfUpdate.self,
]
)

Expand Down
6 changes: 5 additions & 1 deletion Sources/SwiftlyCore/HTTPClient+GitHubAPI.swift
Expand Up @@ -2,10 +2,14 @@ import _StringProcessing
import AsyncHTTPClient
import Foundation

public struct SwiftlyGitHubRelease: Codable {
public let tag: String
}

extension SwiftlyHTTPClient {
/// Get a JSON response from the GitHub REST API.
/// This will use the authorization token set, if any.
private func getFromGitHub<T: Decodable>(url: String) async throws -> T {
public func getFromGitHub<T: Decodable>(url: String) async throws -> T {
var headers: [String: String] = [:]
if let token = self.githubToken ?? ProcessInfo.processInfo.environment["SWIFTLY_GITHUB_TOKEN"] {
headers["Authorization"] = "Bearer \(token)"
Expand Down
137 changes: 59 additions & 78 deletions Sources/SwiftlyCore/HTTPClient.swift
Expand Up @@ -5,91 +5,49 @@ import NIO
import NIOFoundationCompat
import NIOHTTP1

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

/// The default implementation of a toolchain downloader.
/// Downloads toolchains from swift.org.
private struct HTTPToolchainDownloader: ToolchainDownloader {
func downloadToolchain(
_: ToolchainVersion,
url: String,
to destination: String,
reportProgress: @escaping (SwiftlyHTTPClient.DownloadProgress) -> Void
) async throws {
let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: destination))
defer {
try? fileHandle.close()
}

let request = SwiftlyHTTPClient.client.makeRequest(url: url)
let response = try await SwiftlyHTTPClient.client.inner.execute(request, timeout: .seconds(30))

guard case response.status = HTTPResponseStatus.ok else {
throw Error(message: "Received \(response.status) when trying to download \(url)")
}

// Unknown download.swift.org paths redirect to a 404 page which then returns a 200 status.
// As a heuristic for if we've hit the 404 page, we check to see if the content is HTML.
guard !response.headers["Content-Type"].contains(where: { $0.contains("text/html") }) else {
throw SwiftlyHTTPClient.DownloadNotFoundError(url: url)
}

// if defined, the content-length headers announces the size of the body
let expectedBytes = response.headers.first(name: "content-length").flatMap(Int.init)

var receivedBytes = 0
for try await buffer in response.body {
receivedBytes += buffer.readableBytes

try buffer.withUnsafeReadableBytes { bufferPtr in
try fileHandle.write(contentsOf: bufferPtr)
}
reportProgress(SwiftlyHTTPClient.DownloadProgress(
receivedBytes: receivedBytes,
totalBytes: expectedBytes
)
)
}
/// An `HTTPRequestExecutor` backed by an `HTTPClient`.
internal struct HTTPRequestExecutorImpl: HTTPRequestExecutor {
fileprivate static let client = HTTPClientWrapper()

try fileHandle.synchronize()
public func execute(_ request: HTTPClientRequest, timeout: TimeAmount) async throws -> HTTPClientResponse {
try await Self.client.inner.execute(request, timeout: timeout)
}
}

private func makeRequest(url: String) -> HTTPClientRequest {
var request = HTTPClientRequest(url: url)
request.headers.add(name: "User-Agent", value: "swiftly/\(SwiftlyCore.version)")
return request
}

/// HTTPClient wrapper used for interfacing with various REST APIs and downloading things.
public struct SwiftlyHTTPClient {
fileprivate static let client = HTTPClientWrapper()

private struct Response {
let status: HTTPResponseStatus
let buffer: ByteBuffer
}

private let downloader: ToolchainDownloader
private let executor: HTTPRequestExecutor

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

public init(toolchainDownloader: ToolchainDownloader? = nil) {
self.downloader = toolchainDownloader ?? HTTPToolchainDownloader()
public init(executor: HTTPRequestExecutor? = nil) {
self.executor = executor ?? HTTPRequestExecutorImpl()
}

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

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

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

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

public func downloadToolchain(
_ toolchain: ToolchainVersion,
url: String,
to destination: String,
reportProgress: @escaping (DownloadProgress) -> Void
) async throws {
try await self.downloader.downloadToolchain(
toolchain,
url: url,
to: destination,
reportProgress: reportProgress
)
public func downloadFile(url: URL, to destination: URL, reportProgress: @escaping (DownloadProgress) -> Void) async throws {
let fileHandle = try FileHandle(forWritingTo: destination)
defer {
try? fileHandle.close()
}

let request = makeRequest(url: url.absoluteString)
let response = try await self.executor.execute(request, timeout: .seconds(30))

switch response.status {
case .ok:
break
case .notFound:
throw SwiftlyHTTPClient.DownloadNotFoundError(url: url.path)
default:
throw Error(message: "Received \(response.status) when trying to download \(url)")
}

// if defined, the content-length headers announces the size of the body
let expectedBytes = response.headers.first(name: "content-length").flatMap(Int.init)

var lastUpdate = Date()
var receivedBytes = 0
for try await buffer in response.body {
receivedBytes += buffer.readableBytes

try buffer.withUnsafeReadableBytes { bufferPtr in
try fileHandle.write(contentsOf: bufferPtr)
}

let now = Date()
if lastUpdate.distance(to: now) > 0.25 || receivedBytes == expectedBytes {
lastUpdate = now
reportProgress(SwiftlyHTTPClient.DownloadProgress(
receivedBytes: receivedBytes,
totalBytes: expectedBytes
))
}
}

try fileHandle.synchronize()
}
}

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

fileprivate func makeRequest(url: String) -> HTTPClientRequest {
var request = HTTPClientRequest(url: url)
request.headers.add(name: "User-Agent", value: "swiftly")
return request
}

deinit {
try? self.inner.syncShutdown()
}
Expand Down
5 changes: 2 additions & 3 deletions Sources/SwiftlyCore/Platform.swift
Expand Up @@ -33,9 +33,8 @@ public protocol Platform {
/// This will likely have a default implementation.
func listAvailableSnapshots(version: String?) async -> [Snapshot]

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

/// Get a path pointing to a unique, temporary file.
/// This does not need to actually create the file.
Expand Down
2 changes: 2 additions & 0 deletions Sources/SwiftlyCore/SwiftlyCore.swift
@@ -1,5 +1,7 @@
import Foundation

public let version = SwiftlyVersion(major: 0, minor: 1, patch: 0)

/// A separate home directory to use for testing purposes. This overrides swiftly's default
/// home directory location logic.
public var mockedHomeDir: URL?
Expand Down