需求
有的公司的服務器使用堡壘機管理,登錄后需要手動選擇堡壘機對應的序號,需求是在 Intellij IDEA 中,使用自己寫的腳本完成一鍵打包,自動登錄堡壘機,自動選擇目標機器,上傳文件,重啟應用,查看日志輸出。
在IDEA中實現自動化部署的幾種方法
所謂自動化部署,就是根據一個觸發條件,自動運行一段腳本(可以是java,python,js,linux shell),這段腳本的作用就是:打包,上傳,ssh登錄,執行啟動腳本,查看輸出。
使用 Alibaba Cloud Toolkit 插件
這個可以參考其他文章學習,不支持堡壘機自動選擇。
使用IDEA自帶的 deployment 工具
參考其他文章,使用這個工具,先打包需要點擊 1-3次,再上傳點擊 2-3 次,再打開ssh 要點擊 2-3次,再輸入命令 2-6 次,十分繁瑣。
使用第三方的Expect工具
如可以參考 https://github.com/Alexey1Gavrilov/ExpectIt#interacting-with-os-process 這個例子上傳文件, 參考 https://github.com/Alexey1Gavrilov/ExpectIt#interacting-with-ssh-server 這個例子實現自動選擇堡壘機,輸入遠程命令。
自己寫一段腳本實現了 Expect (java,python,js,linux shell都可以,本文以java為例)
也就是本文的方法。
IDEA 系列的 IDE 環境,基於一套 action 系統來運行,每一個按鈕,每一個輸入,都會綁定到一個action,執行action的代碼。
因此,我們的想法就是,利用 IDEA 自己的 action 體系,來自動化部署。
開始
編寫部署腳本
這里以Java為例,如果是在linux或mac作為開發環境,推薦使用shell腳本。如果是python開發,寫python更適合。
新建一個Java工程,引入sshj依賴。
<dependency>
<groupId>com.hierynomus</groupId>
<artifactId>sshj</artifactId>
<version>0.30.0</version>
</dependency>
新建一個Java文件,這里以 AutoDeploy 為類名(基本完成,可根據需要自行修改)。
import net.schmizz.sshj.SSHClient;
import net.schmizz.sshj.common.IOUtils;
import net.schmizz.sshj.common.LoggerFactory;
import net.schmizz.sshj.common.StreamCopier;
import net.schmizz.sshj.connection.channel.direct.Session;
import net.schmizz.sshj.transport.verification.PromiscuousVerifier;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class AutoDeploy {
private static final String[] requireKeys = {"host", "username", "password", "localFile", "remotePath"};
private static final String[] notRequireKeys = {"port", "command"};
static StringBuilder missingKey = new StringBuilder();
static boolean needRecordStdOut = true;
static StringBuffer shellOutBuffer;
public static void main(String[] arg) {
HashMap<String,String> argMap = setDeployArgs(arg);
try {
List<ExpectInput> expectInputList = getExpectInput(arg);
SSHClient ssh = new SSHClient();
ssh.addHostKeyVerifier(new PromiscuousVerifier());
ssh.connect(argMap.get("host"), Integer.parseInt(argMap.getOrDefault("port", "22")));
ssh.authPassword(argMap.get("username"), argMap.get("password"));
// SFTPClient sftp = ssh.newSFTPClient();
// sftp.put(deployArg.localFile, deployArg.remotePath);
// sftp.close();
Session session = ssh.startSession();
if (expectInputList.isEmpty()) {
// 如果沒有需要手動選擇的跳板機, 直接執行命令即可,
Session.Command cmd = session.exec(argMap.getOrDefault("command", "ls"));
String ret = IOUtils.readFully(cmd.getInputStream()).toString();
System.out.println(ret);
return;
}
// 如果類似堡壘機的, 需要手動選擇, 使用下面的方法, 根據正則表達式自動選擇
shellOutBuffer = new StringBuffer(8192);
session.allocateDefaultPTY();
Session.Shell shell = session.startShell();
new Thread(new SysOutThread(shell.getInputStream())).start();
OutputStream output = shell.getOutputStream();
// 按自定義的正則輸入字符 todo 抽象出 expect() 函數
for (ExpectInput expectInput : expectInputList) {
expectStdOut(Pattern.compile(expectInput.expect, Pattern.DOTALL));
String input = findInputFromExceptBefore(expectInput);
output.write(input.getBytes());
output.flush();
}
if (argMap.get("command") != null) {
output.write((argMap.get("command") + "\n").getBytes());
output.flush();
}
// Now make System.in act as stdin. To exit, hit Ctrl+D (since that results in an EOF on System.in)
// This is kinda messy because java only allows console input after you hit return
// But this is just an example... a GUI app could implement a proper PTY
new StreamCopier(System.in, output, LoggerFactory.DEFAULT)
.bufSize(shell.getRemoteMaxPacketSize())
.copy();
session.close();
ssh.close();
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* TODO 添加超時參數, 超時后退出循環
*/
private static void expectStdOut(Pattern pattern) throws InterruptedException {
int size = 0;
while (true) {
// 和上個列表一樣,sleep 1 s
if (size == shellOutBuffer.length()) {
Thread.sleep(500);
continue;
}
size = shellOutBuffer.length();
if (pattern.matcher(shellOutBuffer).matches()) {
Thread.sleep(500);
return;
}
}
}
private static String findInputFromBeforeByPattern(Pattern pattern) {
Matcher matcher = pattern.matcher(shellOutBuffer);
if (matcher.matches()) {
String input = matcher.group(1) + "\n";
shellOutBuffer.delete(0, shellOutBuffer.length());
return input;
} else {
throw new IllegalArgumentException("未找到符合" + pattern.toString() + "的字符");
}
}
private static String findInputFromExceptBefore(ExpectInput expectInput) {
Pattern expectHasGroup = Pattern.compile(".*\\(.*\\).*", Pattern.DOTALL);
if (expectHasGroup.matcher(expectInput.expect).matches()) {
return findInputFromBeforeByPattern(Pattern.compile(expectInput.expect, Pattern.DOTALL));
} else if (expectHasGroup.matcher(expectInput.input).matches()) {
return findInputFromBeforeByPattern(Pattern.compile(expectInput.expect, Pattern.DOTALL));
} else {
shellOutBuffer.delete(0, shellOutBuffer.length());
return expectInput.input.contains("\n") ? expectInput.input : expectInput.input + "\n";
}
}
private static List<ExpectInput> getExpectInput(String[] arg) {
List<ExpectInput> expectInputList = new ArrayList<>();
for (String s : arg) {
int expectIndexInString, inputIdxInMainArgs;
if (s.startsWith("expect") && (expectIndexInString = s.indexOf("=")) >= 0) {
ExpectInput expectInput = new ExpectInput();
expectInput.expect = s.substring(expectIndexInString + 1);
String groupNum = s.substring(6, expectIndexInString);
String inputKey = "input" + groupNum + "=";
if ((inputIdxInMainArgs = indexOf(arg, inputKey)) >= 0) {
expectInput.input = arg[inputIdxInMainArgs].substring(inputKey.length());
}
expectInputList.add(expectInput);
}
}
return expectInputList;
}
/**
* 解析命令行參數, 得到需要的數據
*/
private static HashMap<String, String> setDeployArgs(String[] arg) {
if (arg == null || arg.length == 0) {
throw new IllegalArgumentException("參數為空");
}
HashMap<String, String> argsMap = new HashMap<>();
for (String key : requireKeys) {
int i = indexOf(arg, key + "=");
if (i < 0) {
missingKey.append(key).append(",");
} else {
argsMap.put(key, arg[i].substring(key.length() + 1));
}
}
for (String key : notRequireKeys) {
int i = indexOf(arg, key + "=");
if (i > 0) {
argsMap.put(key, arg[i].substring(key.length() + 1));
}
}
if (missingKey.length() != 0) {
throw new IllegalArgumentException("缺少參數" + missingKey.substring(0, missingKey.length() - 1));
}
return argsMap;
}
private static int indexOf(String[] arg, String key) {
for (int i = 0; i < arg.length; i++) {
if (arg[i].startsWith(key)) {
return i;
}
}
return -1;
}
/**
* shell 輸出線程, 把shell的信息輸出到 System.out
*/
static class SysOutThread implements Runnable {
InputStream input;
public SysOutThread(InputStream input) {
this.input = input;
}
@Override
public void run() {
try {
final byte[] buffer = new byte[8192];
int len = -1;
while ((len = input.read(buffer)) != -1) { // 當等於-1說明沒有數據可以讀取了
System.out.write(buffer, 0, len); // 把讀取到的內容寫到輸出流中
if (needRecordStdOut) {
shellOutBuffer.append(new String(buffer, 0, len));
}
}
} catch (IOException ignored) {
}
}
}
static class ExpectInput {
String expect;
String input;
}
}
運行java文件成功,得到 AutoDeploy.class 等5個class文件,得到所需參數
運行這個java文件成功,並且得到運行時的參數(這個參數是為了后面能夠成功運行 AutoDeploy.class文件,如果是python腳本和shell腳本,就不需要這些參數,直接使用.py或.sh文件即可)。
需要的參數可以在 IDEA 中的輸出中看到
如果像圖片中一樣,需要鼠標單擊一下,讓他顯示出來:
我們需要這里的 -classpath參數,例如,我這里的參數,指向本地maven倉庫的jar文件:
-classpath "D:\Program Files (Dev)\Maven\repository\com\hierynomus\sshj\0.30.0\sshj-0.30.0.jar;D:\Program Files (Dev)\Maven\repository\org\bouncycastle\bcprov-jdk15on\1.66\bcprov-jdk15on-1.66.jar;D:\Program Files (Dev)\Maven\repository\org\bouncycastle\bcpkix-jdk15on\1.66\bcpkix-jdk15on-1.66.jar;D:\Program Files (Dev)\Maven\repository\com\jcraft\jzlib\1.1.3\jzlib-1.1.3.jar;D:\Program Files (Dev)\Maven\repository\com\hierynomus\asn-one\0.4.0\asn-one-0.4.0.jar;D:\Program Files (Dev)\Maven\repository\net\i2p\crypto\eddsa\0.3.0\eddsa-0.3.0.jar;D:\Program Files (Dev)\Maven\repository\org\slf4j\slf4j-api\1.7.26\slf4j-api-1.7.26.jar;D:\Program Files (Dev)\Maven\repository\ch\qos\logback\logback-classic\1.2.3\logback-classic-1.2.3.jar;D:\Program Files (Dev)\Maven\repository\ch\qos\logback\logback-core\1.2.3\logback-core-1.2.3.jar;"
然后我們把編譯好的 class文件找到,復制到一個文件夾中,例如我這里是 C:\Users\bpzj\Desktop\auto-deploy 文件夾。
把這個文件夾路徑添加到上面的 classpath 參數,變成了:
-classpath "C:\Users\bpzj\Desktop\auto-deploy;D:\Program Files (Dev)\Maven\repository\com\hierynomus\sshj\0.30.0\sshj-0.30.0.jar;D:\Program Files (Dev)\Maven\repository\org\bouncycastle\bcprov-jdk15on\1.66\bcprov-jdk15on-1.66.jar;D:\Program Files (Dev)\Maven\repository\org\bouncycastle\bcpkix-jdk15on\1.66\bcpkix-jdk15on-1.66.jar;D:\Program Files (Dev)\Maven\repository\com\jcraft\jzlib\1.1.3\jzlib-1.1.3.jar;D:\Program Files (Dev)\Maven\repository\com\hierynomus\asn-one\0.4.0\asn-one-0.4.0.jar;D:\Program Files (Dev)\Maven\repository\net\i2p\crypto\eddsa\0.3.0\eddsa-0.3.0.jar;D:\Program Files (Dev)\Maven\repository\org\slf4j\slf4j-api\1.7.26\slf4j-api-1.7.26.jar;D:\Program Files (Dev)\Maven\repository\ch\qos\logback\logback-classic\1.2.3\logback-classic-1.2.3.jar;D:\Program Files (Dev)\Maven\repository\ch\qos\logback\logback-core\1.2.3\logback-core-1.2.3.jar;"
因為 sshj 這里使用是logback作為日志,而logback默認的日志級別是 debug,會打印很多沒用的日志信息,所以,我添加了一個logback.xml,再使用參數
-Dlogback.configurationFile=C:\Users\bpzj\Desktop\auto-deploy\logback.xml
如果,不使用logback,會直接使用 jdk 的日志實現。
使用這個java腳本
經過以上,我們的腳本文件(java:.class文件,python:.py文件,shell:.sh文件)已經准備好了,參數也准備好了(只有java需要)
創建一個 Java Scratch 文件,可以是任意類型,這里用文本文件,命名為 AutoDeploy:
這個文件沒啥用,就是為了下面綁定使用
新建一個Configuration,使用 Java Scratch 模板。
填寫參數
如圖,Main Class需要填寫為上面編譯生成的 AutoDeploy。
Path to scratch file,直接選擇到剛剛新建的文件文件,這個配置沒有用處,但是不配的話,IDEA會報錯,無法執行。
VM Options 參數里,填寫第二步得到的參數,是為了 java 能正確的找到 AutoDeploy 類,並執行它的 main 方法。
Program arguments 里,可以寫自定義的參數:
host=192.168.1.166 username=bpzj password=123456 localFile="xxxx" remotepath="xxx" expect1="出現這個字符才輸入" input1="正則表達式,只獲取第一個()的匹配內容" cmd="cd /home/bpzj/ && bash ./restart.sh && tail -f xxx.log"
這里的參數會作為 AutoDeploy main方法的參數 args 傳遞過去,可以在里面解析,定義自己的邏輯。
添加運行腳本前的打包步驟
在 before launch 模塊里,添加這個配置想要打包的文件,如果是 maven 多模塊項目,也可以指定打包某個模塊。
到這里,就配置完成了,現在就可以選中這個 Configuration,點擊后,自動打包,上傳,執行我們自定義的命令。
而且,由於腳本是我們自己寫的,所以極其的靈活。
如果是 python 或 shell腳本,新建一個Configuration,使用Shell Script模塊(需要選擇對應的解釋器,如python.exe,):
TODO