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 the install command on Linux #9

Merged
merged 37 commits into from
Oct 30, 2022
Merged

Implement the install command on Linux #9

merged 37 commits into from
Oct 30, 2022

Conversation

patrickfreed
Copy link
Contributor

@patrickfreed patrickfreed commented Sep 7, 2022

This PR implements the core functionality of the install command on Linux. The main steps are the following:

  • reach out to the GitHub API to find the latest version if an incomplete one was provided (e.g. 5.5)
  • Download the toolchain to a temporary file using AsyncHTTPClient
  • Extract the downloaded archive to ~/.swiftly/toolchains/<toolchain name> using libarchive
  • Add an entry to config.json

You can try this out (on Linux) by creating a ~/.swiftly directory that looks like the following:

.swiftly/
  - config.json (see the example included in this PR)
  - toolchains/

And then run:

swift run swiftly install 5.6

Here's a screenshot of it in action:

swiftly-install

"CLibArchive",
]
),
.systemLibrary(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided to use the libarchive C library rather than any Swift solutions like SWCompression, since all of them needed the entire archive to be present in memory, both compressed and decompressed, and this led to OOM issues with bigger toolchains. I also avoided shelling out to something like tar to avoid having to deal with the possibility that it isn't installed or is missing features (e.g. gzip support).

}

public var fullName: String {
self.platform.fullName
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, the download links contain two different formats for each platform, so I needed to add a new entry in config.json for tracking this. e.g. for Ubuntu 20.04, there's both ubuntu2004 and ubuntu20.04 in this download URL:

https://download.swift.org/swift-5.6.3-release/ubuntu2004/swift-5.6.3-RELEASE/swift-5.6.3-RELEASE-ubuntu20.04.tar.gz

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah that is a pain, not easy one to get around

@@ -22,6 +23,7 @@ let package = Package(
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.target(name: "SwiftlyCore"),
.target(name: "LinuxPlatform", condition: .when(platforms: [.linux])),
.product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I started to implement a basic loading bar type thing, but then I decided to check out what SwiftPM does, and happily they have a separate library which contains this stuff.

reportProgress: { progress in
let now = Date()

guard lastUpdate.distance(to: now) > 0.25 || progress.receivedBytes == progress.totalBytes else {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid spamming the terminal too hard, only print updates every 250ms.

var version: String

@Option(help: ArgumentHelp(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GitHub API has extremely low rate limits for unauthenticated users, so I added an option to specify an authorization token to side step those. Once we move to official Apple APIs, this issue should go away.

/// The `fetch` closure is used to retrieve the next page of results. It accepts the page number as its argument.
/// The `filterMap` closure maps the input GitHub tag to an output. If it returns nil, it will not be included
/// in the returned array.
private static func mapGithubTags<T>(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I use separate GitHub API endpoints to fetch releases vs snapshots even though the /tags one would suffice because there are a lot fewer releases than tags (42 releases vs ~2k tags), and this helps avoid running up against the API rate limits. It's also a decent bit faster for the "happy path".

This convoluted method helps avoid duplicating logic between the two of them.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does GitHub API response parsing belong in a file called HTTP.swift?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the GitHub specific code to HTTPClient+GitHubAPI.swift (also renamed HTTP.swift to HTTPClient.swift). I kept the methods that used the API response parsing, since they should be stable even after we migrate to Apple APIs.

Copy link
Contributor

@adam-fowler adam-fowler left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't had a chance to compile and run this yet, but I have left some comments for you
I also think we should be attempting to add tests for everything where we can.

Sources/SwiftlyCore/ToolchainVerison.swift Show resolved Hide resolved
}

public var fullName: String {
self.platform.fullName
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah that is a pain, not easy one to get around

Sources/LinuxPlatform/Linux.swift Show resolved Hide resolved
@@ -39,10 +70,16 @@ public struct Linux: Platform {

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

public func getTempFilePath() -> URL {
let path = URL(fileURLWithPath: "/tmp/swiftly-\(UUID())")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you cannot assume the temp folder is /tmp. I think you can use FileManager.default.temporaryDirectory

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. I also changed this to just return the path and delegated actually creating the file to Install.swift.

/// The `fetch` closure is used to retrieve the next page of results. It accepts the page number as its argument.
/// The `filterMap` closure maps the input GitHub tag to an output. If it returns nil, it will not be included
/// in the returned array.
private static func mapGithubTags<T>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does GitHub API response parsing belong in a file called HTTP.swift?

public let url: String
}

public static func downloadToolchain(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could just be called download I guess given it could be used on any file

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was originally called that, but I restricted it (via name) for downloading toolchains specifically in order for my 404 detection hack based on Content-Type to make more sense. If we end up needing to download files other than toolchains then I'll name this something more generic and deal with 404s in some other place.

}
}

/// Model of a GitHub REST API release object.
public struct GitHubRelease: Decodable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned above it is probably a good idea to separate all the GH code into a separate file

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Sources/Swiftly/Install.swift Show resolved Hide resolved
Copy link
Contributor Author

@patrickfreed patrickfreed left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added tests for the installation behavior, LMK if the coverage looks good.

Sources/LinuxPlatform/Linux.swift Show resolved Hide resolved
@@ -39,10 +70,16 @@ public struct Linux: Platform {

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

public func getTempFilePath() -> URL {
let path = URL(fileURLWithPath: "/tmp/swiftly-\(UUID())")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. I also changed this to just return the path and delegated actually creating the file to Install.swift.

Sources/Swiftly/Install.swift Show resolved Hide resolved
/// The `fetch` closure is used to retrieve the next page of results. It accepts the page number as its argument.
/// The `filterMap` closure maps the input GitHub tag to an output. If it returns nil, it will not be included
/// in the returned array.
private static func mapGithubTags<T>(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the GitHub specific code to HTTPClient+GitHubAPI.swift (also renamed HTTP.swift to HTTPClient.swift). I kept the methods that used the API response parsing, since they should be stable even after we migrate to Apple APIs.

public let url: String
}

public static func downloadToolchain(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was originally called that, but I restricted it (via name) for downloading toolchains specifically in order for my 404 detection hack based on Content-Type to make more sense. If we end up needing to download files other than toolchains then I'll name this something more generic and deal with 404s in some other place.

}
}

/// Model of a GitHub REST API release object.
public struct GitHubRelease: Decodable {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Sources/SwiftlyCore/ToolchainVerison.swift Show resolved Hide resolved
@@ -0,0 +1,37 @@
name: tests
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a barebones config used to verify the tests pass as written. It will be fleshed out more to cover all the required platforms (#16) and to use the latest release of swiftly to install Swift (#17) instead of manually doing it.

@adam-fowler
Copy link
Contributor

I tried to run this on my linux machine (compiling on macOS with vanilla 5.7 needs Ventura which I don't have). There seems to be a lot of places where it assumes a config file exists. If the file doesn't exist everything dies. Also it is loaded repeatedly, maybe it should be cached.

Sources/SwiftlyCore/HTTPClient+GitHubAPI.swift Outdated Show resolved Hide resolved
}

internal static func execute(version: ToolchainVersion) async throws {
let file = try await Swiftly.currentPlatform.download(version: version)
try Swiftly.currentPlatform.install(from: file, version: version)
guard try !Config.load().installedToolchains.contains(version) else {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Config.load() can throw an error if the file does not exist. You need to deal with the situation where it doesn't exist. Currently it just quits

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the current design, quitting with an error is the desired behavior when no config is present, since swiftly won't have any idea of what platform its running on. The bootstrapping script which downloads and installs swiftly is in charge of creating the configuration file and populating the platform information.

That said, the error this currently fails with is quite ugly, so I've updated it to look something more like the following (this is when the config doesn't exist):

$ swift run swiftly list
Error: Could not load swiftly's configuration file due to error: "Error Domain=NSPOSIXErrorDomain Code=2 "No such file or directory"".
To use swiftly, modify the configuration file to fix the issue or perform a clean installation.

public static let currentPlatform: any Platform = {
do {
let config = try Config.load()
return Linux(name: config.platform.name, namePretty: config.platform.namePretty)
return Linux(platform: config.platform)
} catch {
fatalError("error loading config: \(error)")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if there is no config because this is the first time you ran.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's supposed to exit with an error. See my other comment for more info.

Copy link
Contributor Author

@patrickfreed patrickfreed left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There seems to be a lot of places where it assumes a config file exists. If the file doesn't exist everything dies.

Yep, this is intended behavior. See my threaded comment for more info. If you want to get swiftly running just to test it out, you can run swift test (with a few env variables set) or create ~/.swiftly/config.json using the example one I committed here (that one is for Ubuntu 20.04 but similar ones can work for other Linux variants).

Also it is loaded repeatedly, maybe it should be cached.

Yeah I agree. I actually already have a TODO comment about this in Config.swift. For the purposes of this PR, I think it's okay to not implement the caching behavior (seeing as this is already a big change), and I filed #18 to cover the future work for that.

Sources/SwiftlyCore/HTTPClient+GitHubAPI.swift Outdated Show resolved Hide resolved
}

internal static func execute(version: ToolchainVersion) async throws {
let file = try await Swiftly.currentPlatform.download(version: version)
try Swiftly.currentPlatform.install(from: file, version: version)
guard try !Config.load().installedToolchains.contains(version) else {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the current design, quitting with an error is the desired behavior when no config is present, since swiftly won't have any idea of what platform its running on. The bootstrapping script which downloads and installs swiftly is in charge of creating the configuration file and populating the platform information.

That said, the error this currently fails with is quite ugly, so I've updated it to look something more like the following (this is when the config doesn't exist):

$ swift run swiftly list
Error: Could not load swiftly's configuration file due to error: "Error Domain=NSPOSIXErrorDomain Code=2 "No such file or directory"".
To use swiftly, modify the configuration file to fix the issue or perform a clean installation.

public static let currentPlatform: any Platform = {
do {
let config = try Config.load()
return Linux(name: config.platform.name, namePretty: config.platform.namePretty)
return Linux(platform: config.platform)
} catch {
fatalError("error loading config: \(error)")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's supposed to exit with an error. See my other comment for more info.

ubuntu-base-config.json Outdated Show resolved Hide resolved
import XCTest

final class InstallTests: SwiftlyTests {
func testInstallLatest() async throws {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we get comments detailing what each test is testing for

let expectedBytes = response.headers.first(name: "content-length").flatMap(Int.init)

var receivedBytes = 0
for try await buffer in response.body {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice use of the new async/await APIs

@patrickfreed patrickfreed merged commit d070b1e into main Oct 30, 2022
@patrickfreed patrickfreed deleted the install branch March 28, 2023 03:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants