-
Notifications
You must be signed in to change notification settings - Fork 15
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
Conversation
this will need to change, doing it all in memory leads to OOM
this commit also updates stable version lookups to properly search through GitHub's pagined API results.
"CLibArchive", | ||
] | ||
), | ||
.systemLibrary( |
There was a problem hiding this comment.
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).
Sources/LinuxPlatform/Linux.swift
Outdated
} | ||
|
||
public var fullName: String { | ||
self.platform.fullName |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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"), |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
Sources/SwiftlyCore/HTTP.swift
Outdated
/// 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>( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this 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/LinuxPlatform/Linux.swift
Outdated
} | ||
|
||
public var fullName: String { | ||
self.platform.fullName |
There was a problem hiding this comment.
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
Outdated
@@ -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())") |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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/SwiftlyCore/HTTP.swift
Outdated
/// 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>( |
There was a problem hiding this comment.
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?
Sources/SwiftlyCore/HTTP.swift
Outdated
public let url: String | ||
} | ||
|
||
public static func downloadToolchain( |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
There was a problem hiding this 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
Outdated
@@ -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())") |
There was a problem hiding this comment.
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/SwiftlyCore/HTTP.swift
Outdated
/// 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>( |
There was a problem hiding this comment.
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.
Sources/SwiftlyCore/HTTP.swift
Outdated
public let url: String | ||
} | ||
|
||
public static func downloadToolchain( |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
@@ -0,0 +1,37 @@ | |||
name: tests |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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. |
} | ||
|
||
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 { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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)") |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this 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.
} | ||
|
||
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 { |
There was a problem hiding this comment.
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)") |
There was a problem hiding this comment.
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.
import XCTest | ||
|
||
final class InstallTests: SwiftlyTests { | ||
func testInstallLatest() async throws { |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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
This PR implements the core functionality of the
install
command on Linux. The main steps are the following:~/.swiftly/toolchains/<toolchain name>
usinglibarchive
config.json
You can try this out (on Linux) by creating a
~/.swiftly
directory that looks like the following:And then run:
Here's a screenshot of it in action: