diff --git a/Sources/Formic/Commands/CopyFrom.swift b/Sources/Formic/Commands/CopyFrom.swift index 7672d4d..5ab1709 100644 --- a/Sources/Formic/Commands/CopyFrom.swift +++ b/Sources/Formic/Commands/CopyFrom.swift @@ -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 @@ -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) } } } diff --git a/Sources/Formic/Commands/ShellCommand.swift b/Sources/Formic/Commands/ShellCommand.swift index ba09d64..e2ab931 100644 --- a/Sources/Formic/Commands/ShellCommand.swift +++ b/Sources/Formic/Commands/ShellCommand.swift @@ -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. @@ -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() } @@ -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) } } } diff --git a/Sources/Formic/Commands/VerifyAccess.swift b/Sources/Formic/Commands/VerifyAccess.swift index 35fa3f8..7ae959a 100644 --- a/Sources/Formic/Commands/VerifyAccess.swift +++ b/Sources/Formic/Commands/VerifyAccess.swift @@ -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)) diff --git a/Sources/Formic/DependencyProxies/CommandInvoker.swift b/Sources/Formic/DependencyProxies/CommandInvoker.swift index a40a0aa..8c18ab1 100644 --- a/Sources/Formic/DependencyProxies/CommandInvoker.swift +++ b/Sources/Formic/DependencyProxies/CommandInvoker.swift @@ -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( @@ -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 } @@ -80,6 +84,9 @@ 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. /// @@ -87,7 +94,7 @@ struct ProcessCommandInvoker: CommandInvoker { /// 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") @@ -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() @@ -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( @@ -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 { @@ -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 } diff --git a/Tests/formicTests/CommandInvokerTests.swift b/Tests/formicTests/CommandInvokerTests.swift index f72dcf8..3e8c137 100644 --- a/Tests/formicTests/CommandInvokerTests.swift +++ b/Tests/formicTests/CommandInvokerTests.swift @@ -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")") +} diff --git a/Tests/formicTests/Commands/CopyFromTests.swift b/Tests/formicTests/Commands/CopyFromTests.swift index c19f2e9..a0e9427 100644 --- a/Tests/formicTests/Commands/CopyFromTests.swift +++ b/Tests/formicTests/Commands/CopyFromTests.swift @@ -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") @@ -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") @@ -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) diff --git a/Tests/formicTests/TestDependencies.swift b/Tests/formicTests/TestDependencies.swift index f06fce2..b6133f1 100644 --- a/Tests/formicTests/TestDependencies.swift +++ b/Tests/formicTests/TestDependencies.swift @@ -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 @@ -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 @@ -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 } @@ -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 diff --git a/Tests/formicTests/TestTags.swift b/Tests/formicTests/TestTags.swift index 925537e..6eb789b 100644 --- a/Tests/formicTests/TestTags.swift +++ b/Tests/formicTests/TestTags.swift @@ -2,4 +2,5 @@ import Testing extension Tag { @Tag static var functionalTest: Self + @Tag static var integrationTest: Self }