Skip to content

Commit 2e6deff

Browse files
committed
temporarily (?) shelving Citadel and the 'SSHCommand' option
1 parent 410680c commit 2e6deff

File tree

1 file changed

+151
-151
lines changed

1 file changed

+151
-151
lines changed
Lines changed: 151 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -1,167 +1,167 @@
11
#if canImport(Citadel)
2-
3-
import Citadel
4-
import Crypto // for loading a private key to use with Citadel for authentication
5-
import Dependencies
6-
import Foundation
7-
import Logging
8-
import NIOCore // to interact with ByteBuffer - otherwise it's opaquely buried in Citadel's API response
9-
10-
#if canImport(FoundationNetworking) // Required for Linux
11-
import FoundationNetworking
12-
#endif
13-
14-
/// A command to run on a remote host.
15-
///
16-
/// This (experimental) command uses the Citadel SSH library to connect to a remote host and invoke a command on it.
17-
/// Do not use shell control or redirect operators in the command string.
18-
public struct SSHCommand: Command {
19-
/// The command and arguments to run.
20-
public let commandString: String
21-
/// An optional dictionary of environment variables the system sets when it runs the command.
22-
public let env: [String: String]?
23-
/// A Boolean value that indicates whether a failing command should fail a playbook.
24-
public let ignoreFailure: Bool
25-
/// The retry settings for the command.
26-
public let retry: Backoff
27-
/// The maximum duration to allow for the command.
28-
public let executionTimeout: Duration
29-
/// The ID of the command.
30-
public let id: UUID
31-
32-
/// Creates a new command declaration that the engine runs as a shell command.
33-
/// - Parameters:
34-
/// - argString: the command and arguments to run as a single string separated by spaces.
35-
/// - env: An optional dictionary of environment variables the system sets when it runs the command.
36-
/// - chdir: An optional directory to change to before running the command.
37-
/// - ignoreFailure: A Boolean value that indicates whether a failing command should fail a playbook.
38-
/// - retry: The retry settings for the command.
39-
/// - executionTimeout: The maximum duration to allow for the command.
40-
public init(
41-
_ argString: String, env: [String: String]? = nil, chdir: String? = nil,
42-
ignoreFailure: Bool = false,
43-
retry: Backoff = .never, executionTimeout: Duration = .seconds(120)
44-
) {
45-
self.commandString = argString
46-
self.env = env
47-
self.retry = retry
48-
self.ignoreFailure = ignoreFailure
49-
self.executionTimeout = executionTimeout
50-
id = UUID()
51-
}
522

53-
/// The function that is invoked by an engine to run the command.
54-
/// - Parameters:
55-
/// - host: The host on which the command is run.
56-
/// - logger: An optional logger to record the command output or errors.
57-
/// - Returns: The combined output from the command execution.
58-
@discardableResult
59-
public func run(host: RemoteHost, logger: Logger?) async throws -> CommandOutput {
60-
@Dependency(\.commandInvoker) var invoker: any CommandInvoker
61-
62-
let sshCreds = host.sshAccessCredentials
63-
let targetHostName = host.networkAddress.dnsName ?? host.networkAddress.address.description
64-
return try await self.remoteCommand(
65-
host: targetHostName,
66-
user: sshCreds.username,
67-
identityFile: sshCreds.identityFile,
68-
port: host.sshPort,
69-
strictHostKeyChecking: host.strictHostKeyChecking,
70-
cmd: commandString,
71-
env: env,
72-
logger: logger
73-
)
74-
}
3+
import Citadel
4+
import Crypto // for loading a private key to use with Citadel for authentication
5+
import Dependencies
6+
import Foundation
7+
import Logging
8+
import NIOCore // to interact with ByteBuffer - otherwise it's opaquely buried in Citadel's API response
759

76-
// IMPLEMENTATION NOTE(heckj):
77-
// This is a more direct usage of Citadel SSHClient, not abstracted through a protocol (such as
78-
// CommandInvoker) in order to just "try it out". This means it's not really amenable to use
79-
// and test in api's which use this functionality to make requests and get data.
10+
#if canImport(FoundationNetworking) // Required for Linux
11+
import FoundationNetworking
12+
#endif
8013

81-
// Citadel *also* supports setting up a connecting _once_, and then executing multiple commands,
82-
// which wasn't something you could do with forking commands through Process. I'm not trying to
83-
// take advantage of that capability here.
84-
85-
// Finally, Citadel is particular about the KIND of key you're using - and this iteration is only
86-
// written to handle Ed25519 keys. To make this "real", we'd want to work in how to support RSA
87-
// and DSA keys for SSH authentication as well. Maybe even password authentication.
88-
89-
/// Invoke a command using SSH on a remote host.
14+
/// A command to run on a remote host.
9015
///
91-
/// - Parameters:
92-
/// - host: The remote host to connect to and call the shell command.
93-
/// - user: The user on the remote host to connect as
94-
/// - identityFile: The string path to an SSH identity file.
95-
/// - port: The port to use for SSH to the remote host.
96-
/// - strictHostKeyChecking: A Boolean value that indicates whether to enable strict host checking, defaults to `false`.
97-
/// - cmd: A list of strings that make up the command and any arguments.
98-
/// - env: A dictionary of shell environment variables to apply.
99-
/// - debugPrint: A Boolean value that indicates if the invoker prints the raw command before running it.
100-
/// - Returns: the command output.
101-
/// - Throws: any errors from invoking the shell process, or errors attempting to connect.
102-
func remoteCommand(
103-
host: String,
104-
user: String,
105-
identityFile: String? = nil,
106-
port: Int? = nil,
107-
strictHostKeyChecking: Bool = false,
108-
cmd: String,
109-
env: [String: String]? = nil,
110-
logger: Logger?
111-
) async throws -> CommandOutput {
112-
113-
guard let identityFile = identityFile else {
114-
throw CommandError.noOutputToParse(msg: "No identity file provided for SSH connection")
16+
/// This (experimental) command uses the Citadel SSH library to connect to a remote host and invoke a command on it.
17+
/// Do not use shell control or redirect operators in the command string.
18+
public struct SSHCommand: Command {
19+
/// The command and arguments to run.
20+
public let commandString: String
21+
/// An optional dictionary of environment variables the system sets when it runs the command.
22+
public let env: [String: String]?
23+
/// A Boolean value that indicates whether a failing command should fail a playbook.
24+
public let ignoreFailure: Bool
25+
/// The retry settings for the command.
26+
public let retry: Backoff
27+
/// The maximum duration to allow for the command.
28+
public let executionTimeout: Duration
29+
/// The ID of the command.
30+
public let id: UUID
31+
32+
/// Creates a new command declaration that the engine runs as a shell command.
33+
/// - Parameters:
34+
/// - argString: the command and arguments to run as a single string separated by spaces.
35+
/// - env: An optional dictionary of environment variables the system sets when it runs the command.
36+
/// - chdir: An optional directory to change to before running the command.
37+
/// - ignoreFailure: A Boolean value that indicates whether a failing command should fail a playbook.
38+
/// - retry: The retry settings for the command.
39+
/// - executionTimeout: The maximum duration to allow for the command.
40+
public init(
41+
_ argString: String, env: [String: String]? = nil, chdir: String? = nil,
42+
ignoreFailure: Bool = false,
43+
retry: Backoff = .never, executionTimeout: Duration = .seconds(120)
44+
) {
45+
self.commandString = argString
46+
self.env = env
47+
self.retry = retry
48+
self.ignoreFailure = ignoreFailure
49+
self.executionTimeout = executionTimeout
50+
id = UUID()
11551
}
11652

117-
let urlForData = URL(fileURLWithPath: identityFile)
118-
let dataFromURL = try Data(contentsOf: urlForData) // 411 bytes
119-
120-
let client = try await SSHClient.connect(
121-
host: host,
122-
authenticationMethod: .ed25519(username: "docker-user", privateKey: .init(sshEd25519: dataFromURL)),
123-
hostKeyValidator: .acceptAnything(),
124-
// ^ Please use another validator if at all possible, this is insecure
125-
reconnect: .never
126-
)
127-
128-
var stdoutData: Data = Data()
129-
var stderrData: Data = Data()
130-
131-
do {
132-
let streams = try await client.executeCommandStream(cmd, inShell: true)
133-
134-
for try await event in streams {
135-
switch event {
136-
case .stdout(let stdout):
137-
stdoutData.append(Data(buffer: stdout))
138-
case .stderr(let stderr):
139-
stderrData.append(Data(buffer: stderr))
53+
/// The function that is invoked by an engine to run the command.
54+
/// - Parameters:
55+
/// - host: The host on which the command is run.
56+
/// - logger: An optional logger to record the command output or errors.
57+
/// - Returns: The combined output from the command execution.
58+
@discardableResult
59+
public func run(host: RemoteHost, logger: Logger?) async throws -> CommandOutput {
60+
@Dependency(\.commandInvoker) var invoker: any CommandInvoker
61+
62+
let sshCreds = host.sshAccessCredentials
63+
let targetHostName = host.networkAddress.dnsName ?? host.networkAddress.address.description
64+
return try await self.remoteCommand(
65+
host: targetHostName,
66+
user: sshCreds.username,
67+
identityFile: sshCreds.identityFile,
68+
port: host.sshPort,
69+
strictHostKeyChecking: host.strictHostKeyChecking,
70+
cmd: commandString,
71+
env: env,
72+
logger: logger
73+
)
74+
}
75+
76+
// IMPLEMENTATION NOTE(heckj):
77+
// This is a more direct usage of Citadel SSHClient, not abstracted through a protocol (such as
78+
// CommandInvoker) in order to just "try it out". This means it's not really amenable to use
79+
// and test in api's which use this functionality to make requests and get data.
80+
81+
// Citadel *also* supports setting up a connecting _once_, and then executing multiple commands,
82+
// which wasn't something you could do with forking commands through Process. I'm not trying to
83+
// take advantage of that capability here.
84+
85+
// Finally, Citadel is particular about the KIND of key you're using - and this iteration is only
86+
// written to handle Ed25519 keys. To make this "real", we'd want to work in how to support RSA
87+
// and DSA keys for SSH authentication as well. Maybe even password authentication.
88+
89+
/// Invoke a command using SSH on a remote host.
90+
///
91+
/// - Parameters:
92+
/// - host: The remote host to connect to and call the shell command.
93+
/// - user: The user on the remote host to connect as
94+
/// - identityFile: The string path to an SSH identity file.
95+
/// - port: The port to use for SSH to the remote host.
96+
/// - strictHostKeyChecking: A Boolean value that indicates whether to enable strict host checking, defaults to `false`.
97+
/// - cmd: A list of strings that make up the command and any arguments.
98+
/// - env: A dictionary of shell environment variables to apply.
99+
/// - debugPrint: A Boolean value that indicates if the invoker prints the raw command before running it.
100+
/// - Returns: the command output.
101+
/// - Throws: any errors from invoking the shell process, or errors attempting to connect.
102+
func remoteCommand(
103+
host: String,
104+
user: String,
105+
identityFile: String? = nil,
106+
port: Int? = nil,
107+
strictHostKeyChecking: Bool = false,
108+
cmd: String,
109+
env: [String: String]? = nil,
110+
logger: Logger?
111+
) async throws -> CommandOutput {
112+
113+
guard let identityFile = identityFile else {
114+
throw CommandError.noOutputToParse(msg: "No identity file provided for SSH connection")
115+
}
116+
117+
let urlForData = URL(fileURLWithPath: identityFile)
118+
let dataFromURL = try Data(contentsOf: urlForData) // 411 bytes
119+
120+
let client = try await SSHClient.connect(
121+
host: host,
122+
authenticationMethod: .ed25519(username: "docker-user", privateKey: .init(sshEd25519: dataFromURL)),
123+
hostKeyValidator: .acceptAnything(),
124+
// ^ Please use another validator if at all possible, this is insecure
125+
reconnect: .never
126+
)
127+
128+
var stdoutData: Data = Data()
129+
var stderrData: Data = Data()
130+
131+
do {
132+
let streams = try await client.executeCommandStream(cmd, inShell: true)
133+
134+
for try await event in streams {
135+
switch event {
136+
case .stdout(let stdout):
137+
stdoutData.append(Data(buffer: stdout))
138+
case .stderr(let stderr):
139+
stderrData.append(Data(buffer: stderr))
140+
}
140141
}
142+
143+
// Citadel API appears to provide a return code on failure, but not on success.
144+
145+
let results: CommandOutput = CommandOutput(returnCode: 0, stdOut: stdoutData, stdErr: stderrData)
146+
return results
147+
} catch let error as SSHClient.CommandFailed {
148+
// Have to catch the exceptions thrown by executeCommandStream to get the return code,
149+
// in the event of a command failure.
150+
let results: CommandOutput = CommandOutput(
151+
returnCode: Int32(error.exitCode), stdOut: stdoutData, stdErr: stderrData)
152+
return results
153+
} catch {
154+
throw error
141155
}
142156

143-
// Citadel API appears to provide a return code on failure, but not on success.
144-
145-
let results: CommandOutput = CommandOutput(returnCode: 0, stdOut: stdoutData, stdErr: stderrData)
146-
return results
147-
} catch let error as SSHClient.CommandFailed {
148-
// Have to catch the exceptions thrown by executeCommandStream to get the return code,
149-
// in the event of a command failure.
150-
let results: CommandOutput = CommandOutput(
151-
returnCode: Int32(error.exitCode), stdOut: stdoutData, stdErr: stderrData)
152-
return results
153-
} catch {
154-
throw error
155157
}
156-
157158
}
158-
}
159159

160-
extension SSHCommand: CustomStringConvertible {
161-
/// A textual representation of the command.
162-
public var description: String {
163-
return commandString
160+
extension SSHCommand: CustomStringConvertible {
161+
/// A textual representation of the command.
162+
public var description: String {
163+
return commandString
164+
}
164165
}
165-
}
166166

167167
#endif

0 commit comments

Comments
 (0)