Skip to content

[WIP] Add list-dependencies command #403

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

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions Sources/LinuxPlatform/Linux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,7 @@ public struct Linux: Platform {
}
}

public func verifySystemPrerequisitesForInstall(
_ ctx: SwiftlyCoreContext, platformName: String, version _: ToolchainVersion,
requireSignatureValidation: Bool
) async throws -> String? {
public func getSystemPrerequisites(platformName: String) -> [String] {
// TODO: these are hard-coded until we have a place to query for these based on the toolchain version
// These lists were copied from the dockerfile sources here: https://github.com/apple/swift-docker/tree/ea035798755cce4ec41e0c6dbdd320904cef0421/5.10
let packages: [String] =
Expand Down Expand Up @@ -231,6 +228,10 @@ public struct Linux: Platform {
[]
}

return packages
}

public func getSystemPackageManager(platformName: String) -> String? {
let manager: String? =
switch platformName
{
Expand All @@ -254,6 +255,17 @@ public struct Linux: Platform {
nil
}

return manager
}

public func verifySystemPrerequisitesForInstall(
_ ctx: SwiftlyCoreContext, platformName: String, version _: ToolchainVersion,
requireSignatureValidation: Bool
) async throws -> String? {
let packages: [String] = getSystemPrerequisites(platformName: platformName)

let manager: String? = getSystemPackageManager(platformName: platformName)

if requireSignatureValidation {
guard (try? self.runProgram("gpg", "--version", quiet: true)) != nil else {
var msg = "gpg is not installed. "
Expand Down
12 changes: 12 additions & 0 deletions Sources/MacOSPlatform/MacOS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ public struct MacOS: Platform {
"pkg"
}

public func getSystemPrerequisites(platformName: String) -> [String] {
return []
}

public func isSystemPackageInstalled(_ manager: String?, _ package: String) async -> Bool {
return false
}

public func getSystemPackageManager(platformName: String) -> String? {
return nil
}

public func verifySwiftlySystemPrerequisites() throws {
// All system prerequisites are there for swiftly on macOS
}
Expand Down
98 changes: 98 additions & 0 deletions Sources/Swiftly/ListDependencies.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import ArgumentParser
import Foundation
import SwiftlyCore

struct ListDependencies: SwiftlyCommand {
public static let configuration = CommandConfiguration(
abstract: "List toolchain dependencies required for the given platform."
)

@Option(name: .long, help: "Output format (text, json)")
var format: SwiftlyCore.OutputFormat = .text

@Flag(name: .shortAndLong, help: "Automatically install missing system dependencies with elevated permissions")
var installSystemDeps: Bool = false
Copy link
Member

Choose a reason for hiding this comment

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

issue: Let's call out sudo as the mechanism that will be used to install the system deps here to make that part clear. Sudo may or may not work for some users and machines.


internal static var allowedInstallCommands: Regex<(Substring, Substring, Substring)> { try! Regex("^(apt-get|yum) -y install( [A-Za-z0-9:\\-\\+]+)+$") }

mutating func run() async throws {
try await self.run(Swiftly.createDefaultContext(format: self.format))
}

mutating func run(_ ctx: SwiftlyCoreContext) async throws {
let versionUpdateReminder = try await validateSwiftly(ctx)
defer {
versionUpdateReminder()
}
try await validateLinked(ctx)

var config = try await Config.load(ctx)

// Get the dependencies which must be required for this platform
let dependencies = try await Swiftly.currentPlatform.getSystemPrerequisites(platformName: config.platform.name)
let packageManager = try await Swiftly.currentPlatform.getSystemPackageManager(platformName: config.platform.name)

// Determine which dependencies are missing and which are installed
var installedDeps: [String] = []
var missingDeps: [String] = []
for dependency in dependencies {
if await Swiftly.currentPlatform.isSystemPackageInstalled(packageManager, dependency) {
installedDeps.append(dependency)
} else {
missingDeps.append(dependency)
}
}

try await ctx.output(
ToolchainDependencyInfo(installedDependencies: installedDeps, missingDependencies: missingDeps)
)

if !missingDeps.isEmpty, let packageManager {
let installCmd = "\(packageManager) -y install \(missingDeps.joined(separator: " "))"

let msg = """

For your convenience, would you like swiftly to attempt to use elevated permissions to run the following command in order to install the missing toolchain dependencies (This prompt can be suppressed with the
'--install-system-deps'/'-i' option):
'\(installCmd)'
"""
if !installSystemDeps {
await ctx.message(msg)

guard await ctx.promptForConfirmation(defaultBehavior: true) else {
throw SwiftlyError(message: "System dependency installation has been cancelled")
}
} else {
await ctx.message("Swiftly will run the following command with elevated permissions: \(installCmd)")
}

// This is very security sensitive code here and that's why there's special process handling
// and an allow-list of what we will attempt to run as root. Also, the sudo binary is run directly
// with a fully-qualified path without any checking in order to avoid TOCTOU.
guard try Self.allowedInstallCommands.wholeMatch(in: installCmd) != nil else {
Copy link
Member

Choose a reason for hiding this comment

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

suggestion: We could do this check before presenting the command to the user. They might try running it themselves without checking.

fatalError("Command \(installCmd) does not match allowed patterns for sudo")
}

let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
p.arguments = ["-k"] + ["-p", "Enter your sudo password to run the dependency install command right away (Ctrl-C aborts): "] + installCmd.split(separator: " ").map { String($0) }
Copy link
Member

Choose a reason for hiding this comment

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

thought: This pattern of invoking sudo came from my previous attempt to eliminate prompts where they weren't needed. It might be better to remove the "-k" and "-p" flags here, and just include the details in the above prompts.

do {
try p.run()
// Attach this process to our process group so that Ctrl-C and other signals work
let pgid = tcgetpgrp(STDOUT_FILENO)
if pgid != -1 {
tcsetpgrp(STDOUT_FILENO, p.processIdentifier)
}
defer { if pgid != -1 {
tcsetpgrp(STDOUT_FILENO, pgid)
}}
p.waitUntilExit()
if p.terminationStatus != 0 {
throw SwiftlyError(message: "")
}
} catch {
throw SwiftlyError(message: "Error: sudo could not be run to install the packages. You will need to run the dependency install command manually.")
}
}
}
}
37 changes: 37 additions & 0 deletions Sources/Swiftly/OutputSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,40 @@ struct InstallInfo: OutputData {
try container.encode(self.alreadyInstalled, forKey: .alreadyInstalled)
}
}

struct ToolchainDependencyInfo: OutputData {
let installedDependencies: [String]
let missingDependencies: [String]

private enum CodingKeys: String, CodingKey {
case installedDependencies
case missingDependencies
}

var description: String {
var lines: [String] = []

if installedDependencies.isEmpty && missingDependencies.isEmpty {
lines.append("There are no toolchain dependencies for this platform")
}

if !installedDependencies.isEmpty {
lines.append("Already installed toolchain dependencies")
lines.append("----------------------------")
for dependency in installedDependencies {
lines.append("• \(dependency)")
}
}

if !missingDependencies.isEmpty {
lines.append("\n")
lines.append("Missing toolchain dependencies")
lines.append("----------------------------")
for dependency in missingDependencies {
lines.append("• \(dependency)")
}
}

return lines.joined(separator: "\n")
}
}
1 change: 1 addition & 0 deletions Sources/Swiftly/Swiftly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public struct Swiftly: SwiftlyCommand {
subcommands: [
Install.self,
ListAvailable.self,
ListDependencies.self,
Use.self,
Uninstall.self,
List.self,
Expand Down
15 changes: 15 additions & 0 deletions Sources/SwiftlyCore/Platform.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,21 @@ public protocol Platform: Sendable {
_ ctx: SwiftlyCoreContext, platformName: String, version: ToolchainVersion,
requireSignatureValidation: Bool
) async throws -> String?

/// Returns the list of system requirements needed to install a swift toolchain on the provided platform.
///
/// `platformName` is the platform name of the system
///
func getSystemPrerequisites(platformName: String) -> [String]

/// Returns true if a given system is installed on the system.
func isSystemPackageInstalled(_ manager: String?, _ package: String) async -> Bool

/// Returns the package manger if it exists for the given platform
///
/// `platformName` is the platform name of the system
///
func getSystemPackageManager(platformName: String) -> String?

/// Downloads the signature file associated with the archive and verifies it matches the downloaded archive.
/// Throws an error if the signature does not match.
Expand Down
Loading