Skip to content

Commit

Permalink
working commands to be a single string invoked remotely
Browse files Browse the repository at this point in the history
  • Loading branch information
heckj committed Dec 31, 2024
1 parent dc9886e commit 5144b22
Show file tree
Hide file tree
Showing 11 changed files with 97 additions and 95 deletions.
2 changes: 1 addition & 1 deletion Sources/Formic/Commands/CopyFrom.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public struct CopyFrom: Command {
remotePath: destinationPath)
} else {
return try await invoker.localShell(
cmd: ["cp", tempFile.path, destinationPath], stdIn: nil, env: nil, chdir: nil, debugPrint: false)
cmd: "cp \(tempFile.path) \(destinationPath)", stdIn: nil, env: nil, chdir: nil, debugPrint: false)
}
}
}
Expand Down
47 changes: 15 additions & 32 deletions Sources/Formic/Commands/ShellCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Foundation
/// 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]
public let commandString: 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.
Expand All @@ -20,51 +20,33 @@ public struct ShellCommand: Command {
public let executionTimeout: Duration
/// The ID of the command.
public let id: UUID
/// A Boolean flag that enables additional debug output.
public let debug: Bool

/// Creates a new command declaration that the engine runs as a shell command.
/// - Parameters:
/// - arguments: the command and arguments to run, each argument as a separate string.
/// - argString: the command and arguments to run as a single string separated by spaces.
/// - 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.
/// - debug: An optional Boolean value the presents additional debug output on execution.
public init(
arguments: [String], env: [String: String]? = nil, chdir: String? = nil,
ignoreFailure: Bool = false, retry: Backoff = .never,
executionTimeout: Duration = .seconds(30)
_ argString: String, env: [String: String]? = nil, chdir: String? = nil,
ignoreFailure: Bool = false,
retry: Backoff = .never, executionTimeout: Duration = .seconds(30), debug: Bool = false
) {
self.args = arguments
self.commandString = argString
self.env = env
self.retry = retry
self.ignoreFailure = ignoreFailure
self.executionTimeout = executionTimeout
self.chdir = chdir
self.debug = debug
id = UUID()
}

/// Creates a new command declaration that the engine runs as a shell command.
/// - Parameters:
/// - argString: the command and arguments to run as a single string separated by spaces.
/// - 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.
///
/// This initializer is useful when you have a space-separated string of arguments, and splits all arguments by whitespace.
/// If a command, or argument, requires a whitespace within it, use ``init(arguments:env:chdir:ignoreFailure:retry:executionTimeout:)`` instead.
public init(
_ argString: String, env: [String: String]? = nil, chdir: String? = nil,
ignoreFailure: Bool = false,
retry: Backoff = .never, executionTimeout: Duration = .seconds(30)
) {
let splitArgs: [String] = argString.split(separator: .whitespace).map(String.init)
self.init(
arguments: splitArgs, env: env, chdir: chdir, ignoreFailure: ignoreFailure, retry: retry,
executionTimeout: executionTimeout)
}

/// Runs the command on the host you provide.
/// - Parameter host: The host on which to run the command.
/// - Returns: The command output.
Expand All @@ -81,19 +63,20 @@ public struct ShellCommand: Command {
port: host.sshPort,
strictHostKeyChecking: false,
chdir: chdir,
cmd: args,
cmd: commandString,
env: env,
debugPrint: false
debugPrint: debug
)
} else {
return try await invoker.localShell(cmd: args, stdIn: nil, env: env, chdir: chdir, debugPrint: false)
return try await invoker.localShell(
cmd: commandString, stdIn: nil, env: env, chdir: chdir, debugPrint: debug)
}
}
}

extension ShellCommand: CustomStringConvertible {
/// A textual representation of the command.
public var description: String {
return args.joined(separator: " ")
return commandString
}
}
6 changes: 3 additions & 3 deletions Sources/Formic/Commands/VerifyAccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public struct VerifyAccess: Command {
@discardableResult
public func run(host: Host) async throws -> CommandOutput {
@Dependency(\.commandInvoker) var invoker: any CommandInvoker
let cmdArgs = ["echo", "'hello'"]
let command = "echo 'hello'"

let answer: CommandOutput
if host.remote {
Expand All @@ -49,11 +49,11 @@ public struct VerifyAccess: Command {
port: host.sshPort,
strictHostKeyChecking: false,
chdir: nil,
cmd: cmdArgs,
cmd: command,
env: nil,
debugPrint: false)
} else {
answer = try await invoker.localShell(cmd: cmdArgs, stdIn: nil, env: nil, chdir: nil, debugPrint: false)
answer = try await invoker.localShell(cmd: command, 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
40 changes: 20 additions & 20 deletions Sources/Formic/DependencyProxies/CommandInvoker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import Foundation
//
// - https://github.com/Zollerboy1/SwiftCommand
// I like the structure of SwiftCommand, but it has a few swift6 concurrency warnings about fiddling
// with mutable buffers that are _just_ slightly concerning to me. There also doesn't appear to
// with mutable buffers that are slightly concerning to me. There also doesn't appear to
// be a convenient way to capture STDERR separately (it's mixed together).

// Dependency injection docs:
Expand All @@ -36,7 +36,7 @@ protocol CommandInvoker: Sendable {
port: Int?,
strictHostKeyChecking: Bool,
chdir: String?,
cmd: [String],
cmd: String,
env: [String: String]?,
debugPrint: Bool
) async throws -> CommandOutput
Expand All @@ -54,7 +54,7 @@ protocol CommandInvoker: Sendable {
func getDataAtURL(url: URL) async throws -> Data

func localShell(
cmd: [String],
cmd: String,
stdIn: Pipe?,
env: [String: String]?,
chdir: String?,
Expand Down Expand Up @@ -94,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, chdir: String? = nil, debugPrint: Bool = false
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 @@ -112,9 +112,9 @@ struct ProcessCommandInvoker: CommandInvoker {
}

if let chdir = chdir {
task.arguments = ["sh", "-c", "cd \(chdir); \(cmd.joined(separator: " "))"]
task.arguments = ["sh", "-c", "cd \(chdir); \(cmd)"]
} else {
task.arguments = ["sh", "-c", "\(cmd.joined(separator: " "))"]
task.arguments = ["sh", "-c", "\(cmd)"]
}
if debugPrint {
print(task.arguments?.joined(separator: " ") ?? "nil")
Expand Down Expand Up @@ -186,9 +186,10 @@ struct ProcessCommandInvoker: CommandInvoker {

args.append(localPath)
args.append("\(user)@\(host):\(remotePath)")
let commandString: String = args.joined(separator: " ")
// loose form:
// scp -o StrictHostKeyChecking=no get-docker.sh "docker-user@${IP_ADDRESS}:get-docker.sh"
let rcAndPipe = try await localShell(cmd: args)
let rcAndPipe = try await localShell(cmd: commandString)
return rcAndPipe
}

Expand All @@ -213,31 +214,30 @@ struct ProcessCommandInvoker: CommandInvoker {
port: Int? = nil,
strictHostKeyChecking: Bool = false,
chdir: String?,
cmd: [String],
cmd: String,
env: [String: String]? = nil,
debugPrint: Bool = false
) async throws -> CommandOutput {
var args: [String] = ["ssh"]
var args: String = "ssh"
if strictHostKeyChecking {
args.append("-o")
args.append("StrictHostKeyChecking=no")
args.append(" -o")
args.append(" StrictHostKeyChecking=no")
}
if let identityFile {
args.append("-i")
args.append(identityFile)
args.append(" -i")
args.append(" \(identityFile)")
}
if let port {
args.append("-p")
args.append("\(port)")
args.append(" -p")
args.append(" \(port)")
}
args.append("-t") // request a TTY at the remote host
args.append("\(user)@\(host)")
args.append(" -t") // request a TTY at the remote host
args.append(" \(user)@\(host)")

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

// NOTE(heckj): Ansible's SSH capability
Expand Down
2 changes: 1 addition & 1 deletion Sources/Formic/ResourceTypes/Swarm+Parsers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import Parsing
public struct SwarmJoinCommand: Parser {

func convertToShellCommand(_ argTuple: (String, String, String, String, String, String)) -> ShellCommand {
ShellCommand(arguments: [argTuple.0, argTuple.1, argTuple.2, argTuple.3, argTuple.4, argTuple.5])
ShellCommand("\(argTuple.0) \(argTuple.1) \(argTuple.2) \(argTuple.3) \(argTuple.4) \(argTuple.5)")
}

public var body: some Parser<Substring, ShellCommand> {
Expand Down
24 changes: 19 additions & 5 deletions Tests/formicTests/CommandInvokerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Testing
.timeLimit(.minutes(1)),
.tags(.functionalTest))
func invokeBasicCommandLocally() async throws {
let shellResult = try await ProcessCommandInvoker().localShell(cmd: ["uname"], stdIn: nil, env: nil)
let shellResult = try await ProcessCommandInvoker().localShell(cmd: "uname", stdIn: nil, env: nil)

// print("rc: \(shellResult.returnCode)")
// print("out: \(shellResult.stdoutString ?? "nil")")
Expand All @@ -27,7 +27,7 @@ func invokeBasicCommandLocally() async throws {
.timeLimit(.minutes(1)),
.tags(.integrationTest))
func invokeBasicCommandLocallyWithChdir() async throws {
let shellResult = try await ProcessCommandInvoker().localShell(cmd: ["pwd"], stdIn: nil, env: nil, chdir: "..")
let shellResult = try await ProcessCommandInvoker().localShell(cmd: "pwd", stdIn: nil, env: nil, chdir: "..")

print("rc: \(shellResult.returnCode)")
print("out: \(shellResult.stdoutString ?? "nil")")
Expand All @@ -42,7 +42,7 @@ func invokeBasicCommandLocallyWithChdir() async throws {
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)
cmd: "ls -al", env: nil)
print("rc: \(shellResult.returnCode)")
print("out: \(shellResult.stdoutString ?? "nil")")
print("err: \(shellResult.stderrString ?? "nil")")
Expand All @@ -56,7 +56,7 @@ func invokeRemoteCommand() async throws {
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"])
cmd: "echo ${FIDDLY}", env: ["FIDDLY": "FADDLY"])
print("rc: \(shellResult.returnCode)")
print("out: \(shellResult.stdoutString ?? "nil")")
print("err: \(shellResult.stderrString ?? "nil")")
Expand All @@ -70,7 +70,21 @@ func invokeRemoteCommandWithEnv() async throws {
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)
cmd: "ls -al", env: nil)
print("rc: \(shellResult.returnCode)")
print("out: \(shellResult.stdoutString ?? "nil")")
print("err: \(shellResult.stderrString ?? "nil")")
}

@Test(
"invoking a remote command w/ tilde",
.enabled(if: ProcessInfo.processInfo.environment.keys.contains("INTEGRATION_ENABLED")),
.timeLimit(.minutes(1)),
.tags(.integrationTest))
func invokeRemoteCommandWithTilde() async throws {
let shellResult = try await ProcessCommandInvoker().remoteShell(
host: "127.0.0.1", user: "heckj", identityFile: "~/.orbstack/ssh/id_ed25519", port: 32222, chdir: "..",
cmd: "mkdir ~/.ssh", env: nil)
print("rc: \(shellResult.returnCode)")
print("out: \(shellResult.stdoutString ?? "nil")")
print("err: \(shellResult.stderrString ?? "nil")")
Expand Down
4 changes: 2 additions & 2 deletions Tests/formicTests/Commands/ShellCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import Testing
func shellCommandDeclarationTest() async throws {
let command = ShellCommand("uname")
#expect(command.retry == .never)
#expect(command.args == ["uname"])
#expect(command.commandString == "uname")
#expect(command.env == nil)
#expect(command.id != nil)

Expand All @@ -27,7 +27,7 @@ func shellCommandFullDeclarationTest() async throws {
let command = ShellCommand(
"ls", env: ["PATH": "/usr/bin"],
retry: Backoff(maxRetries: 200, strategy: .exponential(maxDelay: .seconds(60))))
#expect(command.args == ["ls"])
#expect(command.commandString == "ls")
#expect(command.env == ["PATH": "/usr/bin"])
#expect(command.retry == Backoff(maxRetries: 200, strategy: .exponential(maxDelay: .seconds(60))))
#expect(command.description == "ls")
Expand Down
22 changes: 11 additions & 11 deletions Tests/formicTests/EngineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func testEngineRun() async throws {
let cmdExecOut = try await withDependencies { dependencyValues in
dependencyValues.localSystemAccess = TestFileSystemAccess()
dependencyValues.commandInvoker = TestCommandInvoker()
.addSuccess(command: ["uname"], presentOutput: "Darwin\n")
.addSuccess(command: "uname", presentOutput: "Darwin\n")
} operation: {
try await engine.run(host: .localhost, command: cmd)
}
Expand All @@ -40,8 +40,8 @@ func testEngineRunList() async throws {
let cmdExecOut = try await withDependencies { dependencyValues in
dependencyValues.localSystemAccess = TestFileSystemAccess()
dependencyValues.commandInvoker = TestCommandInvoker()
.addSuccess(command: ["uname"], presentOutput: "Darwin\n")
.addSuccess(command: ["whoami"], presentOutput: "docker-user")
.addSuccess(command: "uname", presentOutput: "Darwin\n")
.addSuccess(command: "whoami", presentOutput: "docker-user")
} operation: {
try await engine.run(host: .localhost, displayProgress: false, commands: [cmd1, cmd2])
}
Expand Down Expand Up @@ -75,8 +75,8 @@ func testEngineRunPlaybook() async throws {
let collectedResults = try await withDependencies { dependencyValues in
dependencyValues.localSystemAccess = TestFileSystemAccess()
dependencyValues.commandInvoker = TestCommandInvoker()
.addSuccess(command: ["uname"], presentOutput: "Darwin\n")
.addSuccess(command: ["whoami"], presentOutput: "docker-user")
.addSuccess(command: "uname", presentOutput: "Darwin\n")
.addSuccess(command: "whoami", presentOutput: "docker-user")
} operation: {
try await engine.run(hosts: [.localhost], displayProgress: false, commands: [cmd1, cmd2])
}
Expand Down Expand Up @@ -106,8 +106,8 @@ func testEngineRunPlaybookWithFailure() async throws {
let collectedResults = try await withDependencies { dependencyValues in
dependencyValues.localSystemAccess = TestFileSystemAccess()
dependencyValues.commandInvoker = TestCommandInvoker()
.addSuccess(command: ["uname"], presentOutput: "Darwin\n")
.addFailure(command: ["whoami"], presentOutput: "not tellin!")
.addSuccess(command: "uname", presentOutput: "Darwin\n")
.addFailure(command: "whoami", presentOutput: "not tellin!")
} operation: {
try await engine.run(hosts: [.localhost], displayProgress: false, commands: [cmd1, cmd2])
}
Expand Down Expand Up @@ -140,9 +140,9 @@ func testEngineRunPlaybookWithException() async throws {
let _ = try await withDependencies { dependencyValues in
dependencyValues.localSystemAccess = TestFileSystemAccess()
dependencyValues.commandInvoker = TestCommandInvoker()
.addSuccess(command: ["uname"], presentOutput: "Darwin\n")
.addSuccess(command: "uname", presentOutput: "Darwin\n")
.addException(
command: ["whoami"], errorToThrow: TestError.unknown(msg: "Process failed in something"))
command: "whoami", errorToThrow: TestError.unknown(msg: "Process failed in something"))
} operation: {
try await engine.run(hosts: [.localhost], displayProgress: false, commands: [cmd1, cmd2])
}
Expand All @@ -163,7 +163,7 @@ func testCommandTimeout() async throws {
}

let mockCmdInvoker = TestCommandInvoker()
.addSuccess(command: ["uname"], presentOutput: "Linux\n", delay: .seconds(2))
.addSuccess(command: "uname", presentOutput: "Linux\n", delay: .seconds(2))

await #expect(
throws: CommandError.self, "Slow command should invoke timeout",
Expand Down Expand Up @@ -191,7 +191,7 @@ func testCommandRetry() async throws {
}

let mockCmdInvoker = TestCommandInvoker()
.addFailure(command: ["uname"], presentOutput: "not tellin!")
.addFailure(command: "uname", presentOutput: "not tellin!")

let result = try await withDependencies { dependencyValues in
dependencyValues.localSystemAccess = TestFileSystemAccess()
Expand Down
Loading

0 comments on commit 5144b22

Please sign in to comment.