Skip to content

Commit c4c3959

Browse files
committed
ssh: add basic SSH and SCP tasks
1 parent 739608a commit c4c3959

File tree

8 files changed

+582
-0
lines changed

8 files changed

+582
-0
lines changed

pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
<module>tasks/msteams</module>
4848
<module>tasks/puppet</module>
4949
<module>tasks/s3</module>
50+
<module>tasks/ssh</module>
5051
<module>tasks/terraform</module>
5152
<module>tasks/xml</module>
5253
<module>tasks/zoom</module>
@@ -95,6 +96,12 @@
9596
</exclusion>
9697
</exclusions>
9798
</dependency>
99+
<!-- remove once Concord switches from the original JSch to this fork -->
100+
<dependency>
101+
<groupId>com.github.mwiede</groupId>
102+
<artifactId>jsch</artifactId>
103+
<version>2.27.3</version>
104+
</dependency>
98105
</dependencies>
99106
</dependencyManagement>
100107

tasks/ssh/pom.xml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
3+
<modelVersion>4.0.0</modelVersion>
4+
5+
<parent>
6+
<groupId>com.walmartlabs.concord.plugins</groupId>
7+
<artifactId>concord-plugins-parent</artifactId>
8+
<version>2.8.1-SNAPSHOT</version>
9+
<relativePath>../../pom.xml</relativePath>
10+
</parent>
11+
12+
<artifactId>ssh-tasks</artifactId>
13+
<packaging>jar</packaging>
14+
15+
<dependencies>
16+
<dependency>
17+
<groupId>com.github.mwiede</groupId>
18+
<artifactId>jsch</artifactId>
19+
</dependency>
20+
<dependency>
21+
<groupId>com.walmartlabs.concord.runtime.v2</groupId>
22+
<artifactId>concord-runtime-sdk-v2</artifactId>
23+
<scope>provided</scope>
24+
</dependency>
25+
26+
<dependency>
27+
<groupId>org.junit.jupiter</groupId>
28+
<artifactId>junit-jupiter-api</artifactId>
29+
<scope>test</scope>
30+
</dependency>
31+
<dependency>
32+
<groupId>org.junit.jupiter</groupId>
33+
<artifactId>junit-jupiter-params</artifactId>
34+
<scope>test</scope>
35+
</dependency>
36+
<dependency>
37+
<groupId>org.testcontainers</groupId>
38+
<artifactId>testcontainers</artifactId>
39+
<scope>test</scope>
40+
</dependency>
41+
<dependency>
42+
<groupId>ch.qos.logback</groupId>
43+
<artifactId>logback-classic</artifactId>
44+
<scope>test</scope>
45+
</dependency>
46+
</dependencies>
47+
48+
<build>
49+
<plugins>
50+
<plugin>
51+
<groupId>dev.ybrig.concord</groupId>
52+
<artifactId>concord-maven-plugin</artifactId>
53+
</plugin>
54+
</plugins>
55+
</build>
56+
</project>
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package com.walmartlabs.concord.plugins.ssh;
2+
3+
/*-
4+
* *****
5+
* Concord
6+
* -----
7+
* Copyright (C) 2017 - 2025 Walmart Inc., Concord Authors
8+
* -----
9+
* Licensed under the Apache License, Version 2.0 (the "License");
10+
* you may not use this file except in compliance with the License.
11+
* You may obtain a copy of the License at
12+
*
13+
* http://www.apache.org/licenses/LICENSE-2.0
14+
*
15+
* Unless required by applicable law or agreed to in writing, software
16+
* distributed under the License is distributed on an "AS IS" BASIS,
17+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* See the License for the specific language governing permissions and
19+
* limitations under the License.
20+
* =====
21+
*/
22+
23+
import com.jcraft.jsch.ChannelExec;
24+
import com.jcraft.jsch.JSch;
25+
import com.jcraft.jsch.JSchException;
26+
import com.jcraft.jsch.Session;
27+
import org.slf4j.Logger;
28+
import org.slf4j.LoggerFactory;
29+
30+
import java.nio.file.Files;
31+
import java.nio.file.Paths;
32+
import java.util.List;
33+
34+
public final class JSchUtils {
35+
36+
private static final Logger log = LoggerFactory.getLogger(JSchUtils.class);
37+
38+
public static Exec sshExec(JSch jsch, String user, String password, String host, int port, int timeout) throws JSchException {
39+
Session session = null;
40+
try {
41+
session = jsch.getSession(user, host, port);
42+
if (password != null) {
43+
session.setPassword(password);
44+
session.setConfig("PreferredAuthentications", "password");
45+
}
46+
session.setHostKeyRepository(new NoopHostKeyRepository());
47+
48+
session.connect(timeout);
49+
50+
var channel = (ChannelExec) session.openChannel("exec");
51+
return new Exec(session, channel);
52+
} catch (JSchException e) {
53+
if (session != null) {
54+
session.disconnect();
55+
}
56+
throw e;
57+
}
58+
}
59+
60+
public static JSch initJsch(List<String> identities) {
61+
var jsch = new JSch();
62+
63+
identities.stream()
64+
.map(Paths::get)
65+
.filter(p -> Files.exists(p) && Files.isReadable(p))
66+
.forEach(p -> {
67+
try {
68+
jsch.addIdentity(p.toString());
69+
} catch (JSchException e) {
70+
log.warn("Unable to add {} as a SSH identity: {}", p, e.getMessage());
71+
}
72+
});
73+
74+
return jsch;
75+
}
76+
77+
public record Exec(Session session, ChannelExec channel) implements AutoCloseable {
78+
79+
@Override
80+
public void close() {
81+
session.disconnect();
82+
channel.disconnect();
83+
}
84+
}
85+
86+
private JSchUtils() {
87+
}
88+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.walmartlabs.concord.plugins.ssh;
2+
3+
/*-
4+
* *****
5+
* Concord
6+
* -----
7+
* Copyright (C) 2017 - 2025 Walmart Inc., Concord Authors
8+
* -----
9+
* Licensed under the Apache License, Version 2.0 (the "License");
10+
* you may not use this file except in compliance with the License.
11+
* You may obtain a copy of the License at
12+
*
13+
* http://www.apache.org/licenses/LICENSE-2.0
14+
*
15+
* Unless required by applicable law or agreed to in writing, software
16+
* distributed under the License is distributed on an "AS IS" BASIS,
17+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* See the License for the specific language governing permissions and
19+
* limitations under the License.
20+
* =====
21+
*/
22+
23+
import com.jcraft.jsch.HostKey;
24+
import com.jcraft.jsch.HostKeyRepository;
25+
import com.jcraft.jsch.UserInfo;
26+
27+
public class NoopHostKeyRepository implements HostKeyRepository {
28+
29+
@Override
30+
public int check(String host, byte[] key) {
31+
return HostKeyRepository.OK;
32+
}
33+
34+
@Override
35+
public void add(HostKey hostkey, UserInfo ui) {
36+
}
37+
38+
@Override
39+
public void remove(String host, String type) {
40+
}
41+
42+
@Override
43+
public void remove(String host, String type, byte[] key) {
44+
}
45+
46+
@Override
47+
public String getKnownHostsRepositoryID() {
48+
return "InMemoryRepo";
49+
}
50+
51+
@Override
52+
public HostKey[] getHostKey() {
53+
return new HostKey[0];
54+
}
55+
56+
@Override
57+
public HostKey[] getHostKey(String host, String type) {
58+
return new HostKey[0];
59+
}
60+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package com.walmartlabs.concord.plugins.ssh;
2+
3+
/*-
4+
* *****
5+
* Concord
6+
* -----
7+
* Copyright (C) 2017 - 2025 Walmart Inc., Concord Authors
8+
* -----
9+
* Licensed under the Apache License, Version 2.0 (the "License");
10+
* you may not use this file except in compliance with the License.
11+
* You may obtain a copy of the License at
12+
*
13+
* http://www.apache.org/licenses/LICENSE-2.0
14+
*
15+
* Unless required by applicable law or agreed to in writing, software
16+
* distributed under the License is distributed on an "AS IS" BASIS,
17+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* See the License for the specific language governing permissions and
19+
* limitations under the License.
20+
* =====
21+
*/
22+
23+
import com.walmartlabs.concord.runtime.v2.sdk.Task;
24+
import com.walmartlabs.concord.runtime.v2.sdk.TaskResult;
25+
import com.walmartlabs.concord.runtime.v2.sdk.Variables;
26+
import org.slf4j.Logger;
27+
import org.slf4j.LoggerFactory;
28+
29+
import javax.inject.Named;
30+
import java.io.BufferedInputStream;
31+
import java.io.IOException;
32+
import java.io.InputStream;
33+
import java.nio.file.Files;
34+
import java.nio.file.Paths;
35+
import java.util.List;
36+
37+
import static com.walmartlabs.concord.plugins.ssh.JSchUtils.initJsch;
38+
import static com.walmartlabs.concord.plugins.ssh.JSchUtils.sshExec;
39+
40+
@Named("scp")
41+
public class ScpTask implements Task {
42+
43+
private static final Logger log = LoggerFactory.getLogger(ScpTask.class);
44+
45+
@Override
46+
public TaskResult execute(Variables input) throws Exception {
47+
var host = input.assertString("host");
48+
var user = input.assertString("user");
49+
var password = input.getString("password");
50+
var identities = input.getList("identities", List.<String>of());
51+
var port = input.getInt("port", 22);
52+
var timeout = input.getInt("timeout", 30000);
53+
var src = input.assertString("src");
54+
var dest = input.assertString("dest");
55+
56+
if (!dest.startsWith("/")) {
57+
throw new IOException("The 'dest' path must be absolute: " + dest);
58+
}
59+
60+
var localPath = Paths.get(src);
61+
if (!Files.exists(localPath)) {
62+
throw new IOException("The 'src' file doesn't exist: " + src);
63+
}
64+
if (!Files.isReadable(localPath)) {
65+
throw new IOException("The 'src' file is not readable: " + src);
66+
}
67+
68+
log.info("Sending local file {} to {}@{}:{}{}...", src, user, host, port, dest);
69+
70+
var lastModified = Files.getLastModifiedTime(localPath).toMillis();
71+
var fileSize = Files.size(localPath);
72+
73+
var jsch = initJsch(identities);
74+
try (var exec = sshExec(jsch, user, password, host, port, timeout)) {
75+
var channel = exec.channel();
76+
channel.setCommand("scp -p -t " + dest);
77+
channel.connect();
78+
79+
var out = channel.getOutputStream();
80+
var in = channel.getInputStream();
81+
82+
checkAck(in);
83+
84+
// last modified
85+
out.write(("T" + (lastModified / 1000) + " 0 " + (lastModified / 1000) + " 0\n").getBytes());
86+
out.flush();
87+
checkAck(in);
88+
89+
// file size
90+
out.write(("C0644 " + fileSize + " " + localPath.getFileName().toString() + "\n").getBytes());
91+
out.flush();
92+
checkAck(in);
93+
94+
// send content
95+
try (var fis = new BufferedInputStream(Files.newInputStream(localPath))) {
96+
fis.transferTo(out);
97+
}
98+
99+
// end of content
100+
out.write(0);
101+
out.flush();
102+
checkAck(in);
103+
104+
return TaskResult.success();
105+
}
106+
}
107+
108+
private void checkAck(InputStream in) throws IOException {
109+
int b = in.read();
110+
111+
if (b == 0) {
112+
return;
113+
}
114+
115+
if (b == -1) {
116+
throw new IOException("EOF");
117+
}
118+
119+
if (b == 1 || b == 2) {
120+
StringBuilder sb = new StringBuilder();
121+
int c;
122+
do {
123+
c = in.read();
124+
sb.append((char) c);
125+
} while (c != '\n');
126+
if (b == 1) {
127+
throw new IOException("SCP error: " + sb);
128+
}
129+
throw new IOException("SCP fatal error: " + sb);
130+
}
131+
132+
throw new IllegalStateException("Unknown SCP error");
133+
}
134+
}

0 commit comments

Comments
 (0)