Skip to content

Commit

Permalink
adding chdir to ShellCommand, revising CopyFrom arguments
Browse files Browse the repository at this point in the history
  • Loading branch information
heckj committed Dec 31, 2024
1 parent 48baa31 commit 0c2ba65
Show file tree
Hide file tree
Showing 8 changed files with 123 additions and 30 deletions.
7 changes: 4 additions & 3 deletions Sources/Formic/Commands/CopyFrom.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ public struct CopyFrom: Command {
/// - retry: The retry settings for the command.
/// - executionTimeout: The maximum duration to allow for the command.
public init(
location: String, from: URL, env: [String: String]? = nil, ignoreFailure: Bool = false,
into: String, from: URL, env: [String: String]? = nil, ignoreFailure: Bool = false,
retry: Backoff = .never, executionTimeout: Duration = .seconds(30)
) {
self.from = from
self.env = env
self.destinationPath = location
self.destinationPath = into
self.retry = retry
self.ignoreFailure = ignoreFailure
self.executionTimeout = executionTimeout
Expand Down Expand Up @@ -68,7 +68,8 @@ public struct CopyFrom: Command {
localPath: tempFile.path,
remotePath: destinationPath)
} else {
return try await invoker.localShell(cmd: ["cp", tempFile.path, destinationPath], stdIn: nil, env: nil)
return try await invoker.localShell(
cmd: ["cp", tempFile.path, destinationPath], stdIn: nil, env: nil, chdir: nil, debugPrint: false)
}
}
}
Expand Down
17 changes: 13 additions & 4 deletions Sources/Formic/Commands/ShellCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import Foundation
/// A command to run on a local or remote host.
///
/// This command uses SSH through a local process to invoke commands on a remote host.
/// Do not use shell control or redirect operators in the command string.
public struct ShellCommand: Command {
/// The command and arguments to run.
public let args: [String]
/// An optional dictionary of environment variables the system sets when it runs the command.
public let env: [String: String]?
/// An optional directory to change to before running the command.
public let chdir: String?
/// A Boolean value that indicates whether a failing command should fail a playbook.
public let ignoreFailure: Bool
/// The retry settings for the command.
Expand All @@ -22,18 +25,21 @@ public struct ShellCommand: Command {
/// - Parameters:
/// - arguments: the command and arguments to run, each argument as a separate string.
/// - env: An optional dictionary of environment variables the system sets when it runs the command.
/// - chdir: An optional directory to change to before running the command.
/// - ignoreFailure: A Boolean value that indicates whether a failing command should fail a playbook.
/// - retry: The retry settings for the command.
/// - executionTimeout: The maximum duration to allow for the command.
public init(
arguments: [String], env: [String: String]? = nil, ignoreFailure: Bool = false,
retry: Backoff = .never, executionTimeout: Duration = .seconds(30)
arguments: [String], env: [String: String]? = nil, chdir: String? = nil,
ignoreFailure: Bool = false, retry: Backoff = .never,
executionTimeout: Duration = .seconds(30)
) {
self.args = arguments
self.env = env
self.retry = retry
self.ignoreFailure = ignoreFailure
self.executionTimeout = executionTimeout
self.chdir = chdir
id = UUID()
}

Expand Down Expand Up @@ -88,10 +94,13 @@ public struct ShellCommand: Command {
identityFile: sshCreds.identityFile,
port: host.sshPort,
strictHostKeyChecking: false,
chdir: chdir,
cmd: args,
env: env)
env: env,
debugPrint: false
)
} else {
return try await invoker.localShell(cmd: args, stdIn: nil, env: env)
return try await invoker.localShell(cmd: args, stdIn: nil, env: env, chdir: chdir, debugPrint: false)
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions Sources/Formic/Commands/VerifyAccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,12 @@ public struct VerifyAccess: Command {
identityFile: sshCreds.identityFile,
port: host.sshPort,
strictHostKeyChecking: false,
chdir: nil,
cmd: cmdArgs,
env: nil)
env: nil,
debugPrint: false)
} else {
answer = try await invoker.localShell(cmd: cmdArgs, stdIn: nil, env: nil)
answer = try await invoker.localShell(cmd: cmdArgs, stdIn: nil, env: nil, chdir: nil, debugPrint: false)
}
if answer.stdoutString != "hello" {
return CommandOutput(returnCode: -1, stdOut: nil, stdErr: "Unable to verify access.".data(using: .utf8))
Expand Down
42 changes: 33 additions & 9 deletions Sources/Formic/DependencyProxies/CommandInvoker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ protocol CommandInvoker: Sendable {
identityFile: String?,
port: Int?,
strictHostKeyChecking: Bool,
chdir: String?,
cmd: [String],
env: [String: String]?
env: [String: String]?,
debugPrint: Bool
) async throws -> CommandOutput

func remoteCopy(
Expand All @@ -54,7 +56,9 @@ protocol CommandInvoker: Sendable {
func localShell(
cmd: [String],
stdIn: Pipe?,
env: [String: String]?
env: [String: String]?,
chdir: String?,
debugPrint: Bool
) async throws -> CommandOutput
}

Expand All @@ -80,14 +84,17 @@ struct ProcessCommandInvoker: CommandInvoker {
/// - args: A list of strings that make up the command and any arguments.
/// - stdIn: An optional Pipe to provide `STDIN`.
/// - env: A dictionary of shell environment variables to apply.
/// - cmd: The command to invoke, as a list of strings
/// - debugPrint: A Boolean value that indicates if the invoker prints the raw command before running it.
/// - chdir: An optional directory to change to before running the command.
/// - Returns: The command output.
/// - Throws: any errors from invoking the shell process.
///
/// Errors exposed source from [Process.run()](https://developer.apple.com/documentation/foundation/process/2890105-run),
/// followed by attempting to read the Pipe() outputs (fileHandleForReading.readToEnd()).
/// The types of errors thrown from those locations aren't undocumented.
func localShell(
cmd: [String], stdIn: Pipe? = nil, env: [String: String]? = nil
cmd: [String], stdIn: Pipe? = nil, env: [String: String]? = nil, chdir: String? = nil, debugPrint: Bool = false
) async throws -> CommandOutput {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/env")
Expand All @@ -104,7 +111,14 @@ struct ProcessCommandInvoker: CommandInvoker {
task.environment = ["TERM": "dumb"]
}

task.arguments = cmd
if let chdir = chdir {
task.arguments = ["sh", "-c", "cd \(chdir); \(cmd.joined(separator: " "))"]
} else {
task.arguments = ["sh", "-c", "\(cmd.joined(separator: " "))"]
}
if debugPrint {
print(task.arguments?.joined(separator: " ") ?? "nil")
}

let stdOutPipe = Pipe()
let stdErrPipe = Pipe()
Expand Down Expand Up @@ -187,6 +201,9 @@ struct ProcessCommandInvoker: CommandInvoker {
/// - port: The port to use for SSH to the remote host.
/// - strictHostKeyChecking: A Boolean value that indicates whether to enable strict host checking, defaults to `false`.
/// - cmd: A list of strings that make up the command and any arguments.
/// - chdir: An optional directory to change to before running the command.
/// - env: A dictionary of shell environment variables to apply.
/// - debugPrint: A Boolean value that indicates if the invoker prints the raw command before running it.
/// - Returns: the command output.
/// - Throws: any errors from invoking the shell process.
func remoteShell(
Expand All @@ -195,8 +212,10 @@ struct ProcessCommandInvoker: CommandInvoker {
identityFile: String? = nil,
port: Int? = nil,
strictHostKeyChecking: Bool = false,
chdir: String?,
cmd: [String],
env: [String: String]? = nil
env: [String: String]? = nil,
debugPrint: Bool = false
) async throws -> CommandOutput {
var args: [String] = ["ssh"]
if strictHostKeyChecking {
Expand All @@ -214,13 +233,18 @@ struct ProcessCommandInvoker: CommandInvoker {
args.append("-t") // request a TTY at the remote host
args.append("\(user)@\(host)")

args.append(contentsOf: cmd)
let joinedCmd = cmd.joined(separator: " ")
if let chdir = chdir {
args.append("cd \(chdir);\(joinedCmd)")
} else {
args.append("\(joinedCmd)")
}

// NOTE(heckj): Ansible's SSH capability
// (https://github.com/ansible/ansible/blob/devel/lib/ansible/plugins/connection/ssh.py)
// does this with significantly more finness, checking the output as it's returned and providing a pass
// to use sshpass to authenticate, or to escalate commands with sudo and a password, before the core
// command is invoked.
// does this with significantly more finesse. It checks the output as it's returned and
// provides a password through that uses sshpass to authenticate, or escalates commands
// with sudo and a password, before the core command is invoked.
let rcAndPipe = try await localShell(cmd: args, env: env)
return rcAndPipe
}
Expand Down
59 changes: 59 additions & 0 deletions Tests/formicTests/CommandInvokerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,67 @@ import Testing
func invokeBasicCommandLocally() async throws {
let shellResult = try await ProcessCommandInvoker().localShell(cmd: ["uname"], stdIn: nil, env: nil)

// print("rc: \(shellResult.returnCode)")
// print("out: \(shellResult.stdoutString ?? "nil")")
// print("err: \(shellResult.stderrString ?? "nil")")

// results expected on a Linux host only
#expect(shellResult.returnCode == 0)
#expect(shellResult.stdoutString == "Linux\n")
#expect(shellResult.stderrString == nil)
}

@Test(
"invoking a local command w/ chdir",
.enabled(if: ProcessInfo.processInfo.environment.keys.contains("INTEGRATION_ENABLED")),
.timeLimit(.minutes(1)),
.tags(.integrationTest))
func invokeBasicCommandLocallyWithChdir() async throws {
let shellResult = try await ProcessCommandInvoker().localShell(cmd: ["pwd"], stdIn: nil, env: nil, chdir: "..")

print("rc: \(shellResult.returnCode)")
print("out: \(shellResult.stdoutString ?? "nil")")
print("err: \(shellResult.stderrString ?? "nil")")
}

@Test(
"invoking a remote command",
.enabled(if: ProcessInfo.processInfo.environment.keys.contains("INTEGRATION_ENABLED")),
.timeLimit(.minutes(1)),
.tags(.integrationTest))
func invokeRemoteCommand() async throws {
let shellResult = try await ProcessCommandInvoker().remoteShell(
host: "127.0.0.1", user: "heckj", identityFile: "~/.orbstack/ssh/id_ed25519", port: 32222, chdir: nil,
cmd: ["ls", "-al"], env: nil)
print("rc: \(shellResult.returnCode)")
print("out: \(shellResult.stdoutString ?? "nil")")
print("err: \(shellResult.stderrString ?? "nil")")
}

@Test(
"invoking a remote command with Env",
.enabled(if: ProcessInfo.processInfo.environment.keys.contains("INTEGRATION_ENABLED")),
.timeLimit(.minutes(1)),
.tags(.integrationTest))
func invokeRemoteCommandWithEnv() async throws {
let shellResult = try await ProcessCommandInvoker().remoteShell(
host: "127.0.0.1", user: "heckj", identityFile: "~/.orbstack/ssh/id_ed25519", port: 32222, chdir: nil,
cmd: ["echo", "${FIDDLY}"], env: ["FIDDLY": "FADDLY"])
print("rc: \(shellResult.returnCode)")
print("out: \(shellResult.stdoutString ?? "nil")")
print("err: \(shellResult.stderrString ?? "nil")")
}

@Test(
"invoking a remote command w/ chdir",
.enabled(if: ProcessInfo.processInfo.environment.keys.contains("INTEGRATION_ENABLED")),
.timeLimit(.minutes(1)),
.tags(.integrationTest))
func invokeRemoteCommandWithChdir() async throws {
let shellResult = try await ProcessCommandInvoker().remoteShell(
host: "127.0.0.1", user: "heckj", identityFile: "~/.orbstack/ssh/id_ed25519", port: 32222, chdir: "..",
cmd: ["ls", "-al"], env: nil)
print("rc: \(shellResult.returnCode)")
print("out: \(shellResult.stdoutString ?? "nil")")
print("err: \(shellResult.stderrString ?? "nil")")
}
6 changes: 3 additions & 3 deletions Tests/formicTests/Commands/CopyFromTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import Testing
func copyFromCommandDeclarationTest() async throws {

let url: URL = try #require(URL(string: "http://somehost.com/datafile"))
let command = CopyFrom(location: "/dest/path", from: url)
let command = CopyFrom(into: "/dest/path", from: url)
#expect(command.retry == .never)
#expect(command.from == url)
#expect(command.destinationPath == "/dest/path")
Expand All @@ -21,7 +21,7 @@ func copyFromCommandDeclarationTest() async throws {
func copyFromCommandFullDeclarationTest() async throws {
let url: URL = try #require(URL(string: "http://somehost.com/datafile"))
let command = CopyFrom(
location: "/dest/path", from: url,
into: "/dest/path", from: url,
retry: Backoff(maxRetries: 100, strategy: .fibonacci(maxDelay: .seconds(60))))
#expect(command.from == url)
#expect(command.destinationPath == "/dest/path")
Expand All @@ -48,7 +48,7 @@ func testInvokingCopyFromCommand() async throws {
let cmdOut = try await withDependencies {
$0.commandInvoker = testInvoker
} operation: {
try await CopyFrom(location: "/dest/path", from: url).run(host: host)
try await CopyFrom(into: "/dest/path", from: url).run(host: host)
}

#expect(cmdOut.returnCode == 0)
Expand Down
15 changes: 6 additions & 9 deletions Tests/formicTests/TestDependencies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Foundation
@testable import Formic

struct TestCommandInvoker: CommandInvoker {

func getDataAtURL(url: URL) async throws -> Data {
if let data = proxyData[url] {
return data
Expand Down Expand Up @@ -36,8 +37,8 @@ struct TestCommandInvoker: CommandInvoker {
}

func remoteShell(
host: String, user: String, identityFile: String?, port: Int?, strictHostKeyChecking: Bool, cmd: [String],
env: [String: String]?
host: String, user: String, identityFile: String?, port: Int?, strictHostKeyChecking: Bool, chdir: String?,
cmd: [String], env: [String: String]?, debugPrint: Bool
) async throws -> Formic.CommandOutput {
if let errorToThrow = proxyErrors[cmd] {
throw errorToThrow
Expand All @@ -50,7 +51,9 @@ struct TestCommandInvoker: CommandInvoker {
return CommandOutput(returnCode: 0, stdOut: "".data(using: .utf8), stdErr: nil)
}

func localShell(cmd: [String], stdIn: Pipe?, env: [String: String]?) async throws -> Formic.CommandOutput {
func localShell(cmd: [String], stdIn: Pipe?, env: [String: String]?, chdir: String?, debugPrint: Bool) async throws
-> Formic.CommandOutput
{
if let errorToThrow = proxyErrors[cmd] {
throw errorToThrow
}
Expand Down Expand Up @@ -109,12 +112,6 @@ struct TestCommandInvoker: CommandInvoker {
return TestCommandInvoker(proxyResults, existingErrors, proxyData)
}

// func addURLException(command: [String], errorToThrow: (any Error)) -> Self {
// var existingErrors = proxyErrors
// existingErrors[command] = errorToThrow
// return TestCommandInvoker(proxyResults, existingErrors, proxyData)
// }

func addData(url: URL, data: Data?) -> Self {
guard let data = data else {
return self
Expand Down
1 change: 1 addition & 0 deletions Tests/formicTests/TestTags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ import Testing

extension Tag {
@Tag static var functionalTest: Self
@Tag static var integrationTest: Self
}

0 comments on commit 0c2ba65

Please sign in to comment.