carton/Sources/SwiftToolchain/ToolchainManagement.swift

323 lines
10 KiB
Swift

// Copyright 2020 Carton contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import CartonHelpers
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
internal func processStringOutput(_ arguments: [String]) throws -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: arguments[0])
process.arguments = Array(arguments.dropFirst())
let pipe = Pipe()
process.standardOutput = pipe
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return String(data: data, encoding: .utf8)
}
private let versionRegEx = #/(?:swift-)?(.+-.)-.+\\.tar.gz/#
private struct Release: Decodable {
struct Asset: Decodable {
enum CodingKeys: String, CodingKey {
case name
case url = "browser_download_url"
}
let name: String
let url: Foundation.URL
}
let assets: [Asset]
}
public class ToolchainSystem {
let fileSystem: FileSystem
let userXCToolchainResolver: XCToolchainResolver?
let cartonToolchainResolver: CartonToolchainResolver
let resolvers: [ToolchainResolver]
let githubToken: String?
public init(
fileSystem: FileSystem,
githubToken: String? = nil
) throws {
self.fileSystem = fileSystem
self.githubToken = githubToken ?? ProcessInfo.processInfo.environment["GITHUB_TOKEN"]
let userLibraryPath = NSSearchPathForDirectoriesInDomains(
.libraryDirectory,
.userDomainMask,
true
).first
let rootLibraryPath = NSSearchPathForDirectoriesInDomains(
.libraryDirectory,
.localDomainMask,
true
).first
userXCToolchainResolver = try userLibraryPath.flatMap {
XCToolchainResolver(libraryPath: try AbsolutePath(validating: $0), fileSystem: fileSystem)
}
let rootXCToolchainResolver = try rootLibraryPath.flatMap {
XCToolchainResolver(libraryPath: try AbsolutePath(validating: $0), fileSystem: fileSystem)
}
let xctoolchainResolvers: [ToolchainResolver] = [
userXCToolchainResolver, rootXCToolchainResolver,
].compactMap { $0 }
cartonToolchainResolver = try CartonToolchainResolver(fileSystem: fileSystem)
resolvers =
try [
cartonToolchainResolver,
SwiftEnvToolchainResolver(fileSystem: fileSystem),
] + xctoolchainResolvers
}
private var libraryPaths: [AbsolutePath] {
get throws {
try NSSearchPathForDirectoriesInDomains(
.libraryDirectory, [.localDomainMask], true
).map { try AbsolutePath(validating: $0) }
}
}
public var swiftVersionPath: AbsolutePath {
guard let cwd = fileSystem.currentWorkingDirectory else {
fatalError()
}
return cwd.appending(component: ".swift-version")
}
private func getDirectoryPaths(_ directoryPath: AbsolutePath) throws -> [AbsolutePath] {
if fileSystem.isDirectory(directoryPath) {
return try fileSystem.getDirectoryContents(directoryPath)
.map { directoryPath.appending(component: $0) }
} else {
return []
}
}
func inferSwiftVersion(
from versionSpec: String? = nil,
_ terminal: InteractiveWriter
) throws -> String {
if let versionSpec = versionSpec {
if let url = URL(string: versionSpec),
let filename = url.pathComponents.last,
let match = try versionRegEx.firstMatch(in: filename)?.0
{
terminal.logLookup("Inferred swift version: ", match)
return String(match)
} else {
return versionSpec
}
}
guard let version = try fetchLocalSwiftVersion(), version.contains("wasm") else {
return defaultToolchainVersion
}
return version
}
private func checkAndLog(
installationPath: AbsolutePath,
_ terminal: InteractiveWriter
) throws -> AbsolutePath? {
let swiftPath = installationPath.appending(components: "usr", "bin", "swift")
terminal.logLookup("- checking Swift compiler path: ", swiftPath)
guard fileSystem.exists(swiftPath, followSymlink: true) else { return nil }
terminal.write("Inferring basic settings...\n", inColor: .yellow)
terminal.logLookup("- swift executable: ", swiftPath)
if let output = try processStringOutput([swiftPath.pathString, "--version"]) {
terminal.write(output)
}
return swiftPath
}
private func inferDownloadURL(
from version: String,
_ client: URLSession,
_ terminal: InteractiveWriter
) async throws -> Foundation.URL? {
let releaseURL = """
https://api.github.com/repos/swiftwasm/swift/releases/tags/\
swift-\(version)
"""
terminal.logLookup("Fetching release assets from ", releaseURL)
let decoder = JSONDecoder()
var request = URLRequest(url: URL(string: releaseURL)!)
if let githubToken {
request.setValue("Bearer \(githubToken)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw ToolchainError.notHTTPURLResponse(url: releaseURL)
}
guard 200..<300 ~= httpResponse.statusCode else {
throw ToolchainError.invalidResponse(
url: releaseURL, status: httpResponse.statusCode, body: data
)
}
terminal.write("Response contained body, parsing it now...\n", inColor: .green)
let release = try decoder.decode(Release.self, from: data)
#if arch(x86_64)
let archSuffix = "x86_64"
#elseif arch(arm64)
#if os(macOS)
let archSuffix = "arm64"
#elseif os(Linux)
let archSuffix = "aarch64"
#endif
#endif
#if os(macOS)
let platformSuffixes = ["osx", "catalina", "macos"]
#elseif os(Linux)
let platformSuffixes = ["linux", try self.inferLinuxDistributionSuffix()]
#endif
terminal.logLookup(
"Response successfully parsed, choosing from this number of assets: ",
release.assets.count
)
let nameSuffixes = platformSuffixes.map { "\($0)_\(archSuffix)" }
return release.assets.map(\.url).filter { url in
nameSuffixes.contains { url.absoluteString.contains($0) }
&& !url.absoluteString.contains(".artifactbundle.")
}.first
}
private func inferLinuxDistributionSuffix() throws -> String {
guard
let releaseFile = [
AbsolutePath.root.appending(components: "etc", "lsb-release"),
AbsolutePath.root.appending(components: "etc", "os-release"),
].first(where: fileSystem.isFile)
else {
throw ToolchainError.unsupportedOperatingSystem
}
let releaseData = try fileSystem.readFileContents(releaseFile).description
if releaseData.contains("DISTRIB_RELEASE=18.04") {
return "ubuntu18.04"
} else if releaseData.contains("DISTRIB_RELEASE=20.04") {
return "ubuntu20.04"
} else if releaseData.contains("DISTRIB_RELEASE=22.04") {
return "ubuntu22.04"
} else if releaseData.contains(#"PRETTY_NAME="Amazon Linux 2""#) {
return "amazonlinux2"
} else {
throw ToolchainError.unsupportedOperatingSystem
}
}
public struct SwiftPath {
public var version: String
public var swift: AbsolutePath
public var toolchain: AbsolutePath
}
/** Infer `swift` binary path matching a given version if any is present, or infer the
version from the `.swift-version` file. If neither version is installed, download it.
*/
public func inferSwiftPath(
from versionSpec: String? = nil,
_ terminal: InteractiveWriter
) async throws -> SwiftPath {
let specURL = versionSpec.flatMap { (string: String) -> Foundation.URL? in
guard
let url = Foundation.URL(string: string),
let scheme = url.scheme,
["http", "https"].contains(scheme)
else { return nil }
return url
}
let swiftVersion = try inferSwiftVersion(from: versionSpec, terminal)
for resolver in resolvers {
let toolchain = resolver.toolchain(for: swiftVersion)
if let path = try checkAndLog(installationPath: toolchain, terminal) {
return SwiftPath(version: swiftVersion, swift: path, toolchain: toolchain)
}
}
let client = URLSession.shared
let downloadURL: Foundation.URL
if let specURL = specURL {
downloadURL = specURL
} else if let inferredURL = try await inferDownloadURL(from: swiftVersion, client, terminal) {
downloadURL = inferredURL
} else {
terminal.write("The Swift version \(swiftVersion) was not found\n", inColor: .red)
throw ToolchainError.invalidVersion(version: swiftVersion)
}
terminal.write(
"Local installation of Swift version \(swiftVersion) not found\n",
inColor: .yellow
)
terminal.logLookup("Swift toolchain/SDK download URL: ", downloadURL)
let installationPath = try await installSDK(
version: swiftVersion,
from: downloadURL,
to: cartonToolchainResolver.cartonSDKPath,
terminal
)
guard let path = try checkAndLog(installationPath: installationPath, terminal) else {
throw ToolchainError.invalidInstallationArchive(installationPath)
}
return SwiftPath(version: swiftVersion, swift: path, toolchain: installationPath)
}
public func fetchAllSwiftVersions() throws -> [String] {
resolvers.flatMap { (try? $0.fetchVersions()) ?? [] }
.filter { fileSystem.isDirectory($0.path) }
.map(\.version)
.sorted()
}
public func fetchLocalSwiftVersion() throws -> String? {
guard fileSystem.isFile(swiftVersionPath),
let version = try fileSystem.readFileContents(swiftVersionPath)
.validDescription?
// get the first line of the file
.components(separatedBy: CharacterSet.newlines).first
else { return nil }
return version
}
public static func isSnapshotVersion(_ version: String) -> Bool {
version.contains("SNAPSHOT")
}
}