使用Ssh2-Promise进行一键部署
目的
在前端项目中,项目开发完成后的构建、部署是个问题,在不使用docker
、jinkins
等工具的情况下,实现代码的自动部署或一键部署
常规方法
利用Git
将代码clone到目标服务器上,然后在目标服务器上运行构建命令,再将构建结果文件移到 部署目录
新方法
使用Ssh2-promise
通过代码连接服务器,将打包好的代码文件进行上传和不是
基本原理
在某台存放有项目开发代码的电脑上,执行项目的构建,生成dist/**/*
等项目运行文件
编写node程序,使用archiver
包进行文件的压缩打包
使用ssh2-promise
的sftp
、exec
等工具上传压缩后的运行文件,并远程执行服务器命令,完成项目运行文件在服务器上的解压缩、部署等操作。
代码
操作类: DeployProcess
/**
* 执行部署操作的处理类
*
* @copyright (c)2022
* @author AndyLau <373804860@qq.com>
* @package com.diansan.omni-channel-front
* @version V1.0.1
* @since 2022-12-09 15:31:15
*/
import SSH2Promise from 'ssh2-promise'
import * as fs from 'fs'
import archiver from 'archiver'
import { TransferOptions } from 'ssh2'
import SSHConfig from 'ssh2-promise/lib/sshConfig'
import { resolveFromSrc } from '../util'
import dayjs from 'dayjs'
export type ConnectConfig = SSHConfig & {
privateKeyPath?: string
}
export type SftpUploadOptions = {
localPath: string,
remotePath: string,
options?: TransferOptions
}
export type DeployConfig = {
/**
* 打包后文件目录
* 默认为dist目录
*/
distPath?: string,
/**
* 临时目录,用于暂存打包后的zip文件, 本机文件系统的绝对路径
* 默认项目根目录下的 tmp
*/
tmpPath?: string,
/**
* 上传后的文件命名前缀
* 默认:dist
* 最终生成文件命名格式: {distZipFilePrefix}.{YYMMDD}.{HHmm}.zip
*/
distZipFilePrefix?: string,
/**
* 打包后的文件压缩包上传到服务器的目录路径
* 默认:/data/download/dist/windyland
*/
remoteUploadPath?: string,
/**
* 服务上最终文件的部署目录。 程序会以软链接的形式进行挂载
*/
remoteDeployPath?: string
}
class DeployProcess {
private _connConfig: ConnectConfig | undefined
private _deployConfig: DeployConfig = {}
private _client: SSH2Promise | undefined
private _connectP: Promise<void> | undefined
private _connected = false
constructor(connConfig: ConnectConfig, deployConfig: DeployConfig) {
this.setConnectConfig(connConfig)
this.setDeployConfig(deployConfig)
}
setConnectConfig(config: ConnectConfig) {
const m = this
m._connConfig = config
if (!config.privateKey && config.privateKeyPath) {
m._connConfig.privateKey = fs.readFileSync(config.privateKeyPath)
}
}
setDeployConfig(config: DeployConfig) {
const m = this
m._deployConfig = config
}
/**
* log message
* @private
*/
_log(...msgList: Array<string|number>) {
console.log('[Deploy] - ', ...msgList)
}
/**
* log message
* @private
*/
_err(err: Error|null|undefined, ...msgList: Array<string|number>) {
console.log('[Deploy error] - ', ...msgList)
err && console.error(err)
process.exit(1)
}
/**
* @param fmt eg.: YYYY-MM-DDTHH:mm:ss
*/
_dateStr(fmt = 'YYYY-MM-DD HH:mm:ss'){
return dayjs().format(fmt)
}
_isString(v: string|undefined): v is string{
return typeof v === 'string'
}
_isNotBlank(v: string|undefined): v is string {
return !!v
}
/**
* @private
*/
async _doRunDeploy() {
const m = this
const { remoteUploadPath, remoteDeployPath } = m._deployConfig
const { distPath = resolveFromSrc('../dist'), distZipFilePrefix = 'dist', tmpPath = resolveFromSrc('../tmp') } = m._deployConfig
// 参数检查
if (!m._isNotBlank(remoteUploadPath) || !/^\/data/.test(remoteUploadPath)) {
m._err(null, 'deployConfig.remoteUploadPath cant not be null and should start with: /data')
}
if (!m._isNotBlank(remoteDeployPath) || !/^\/data\/www/.test(remoteDeployPath)) {
m._err(null, 'deployConfig.remoteDeployPath cant not be null and should start with: /data/www')
}
// 变量
const dateStr = m._dateStr('YYMMDD')
const timeStr = m._dateStr('HHmm')
const zipFileBaseName = `${distZipFilePrefix}.${dateStr}.${timeStr}`
const zipFileName = `${zipFileBaseName}.zip`
const zipFile = tmpPath + '/' + zipFileName
const remoteDistFile = `${remoteUploadPath}/${zipFileName}`
const remoteDistPath = `${remoteUploadPath}/${zipFileBaseName}`
m._log('---------- deploy process start! ----------')
m._log(`Archiving dist files to zip of path: ${distPath}`)
if (!fs.existsSync(tmpPath)) {
fs.mkdirSync(tmpPath, { recursive: true })
}
await m.zipFile(distPath, zipFile)
m._log(`Archive dist files finished: ${zipFile}`)
// 构建上传目录
const created = await m.remoteMkDir(remoteUploadPath as string)
if (created) {
m._log(`Remote upload path created: ${remoteUploadPath}`)
}
// 开始上传
m._log('Uploading zipped dist files...')
await m.sftpUpload({
localPath: zipFile,
remotePath: remoteDistFile
})
m._log(`Upload zipped dist files finished:${remoteDistFile}`)
// 解压、部署到www目录
m._log('Unzipping dist files...')
const cmd = `cd ${remoteUploadPath} && unzip ${zipFileName} -d ${zipFileBaseName}`
m._log('COMMAND:' + cmd)
await m.exec(cmd)
m._log(`Unzip dist files finished:${remoteDistPath}`)
// 使用软链接进行部署
await m.exec(`rm -rf ${remoteDeployPath} && ln -s ${remoteDistPath} ${remoteDeployPath}`)
m._log(`Dist files deployed to:${remoteDeployPath}`)
// 部署完成后,自动清理远程目录下的历史文件
await m.exec(`rm -rf ${remoteDistFile}`)
await m.autoCleanDeployTmpFiles(remoteUploadPath as string, zipFileBaseName)
m._log('Clean files finished')
await m.closeSsh()
}
async autoCleanDeployTmpFiles(remoteUploadPath: string, distDirName: string){
const m = this
const list = await m.readDir(remoteUploadPath)
const dirNameList = (list || []).map(l => l.filename)
for (const dirName of dirNameList) {
if (dirName !== distDirName) {
await m.exec(`rm -rf ${remoteUploadPath}/${dirName}`)
}
}
}
async runDeploy() {
const m = this
try {
await this._doRunDeploy()
} catch (err) {
this._err(err as Error)
}
m._log('deploy finished')
}
/**
* 压缩文件
*/
async zipFile(dirPath: string, zipPath: string): Promise<void> {
const archiverIns = archiver('zip', { zlib: { level: 9 } })
// 输出文件的流
const output = fs.createWriteStream(zipPath)
archiverIns.pipe(output)
// 压缩目录
archiverIns.directory(dirPath, false)
// archiver操作结束
archiverIns.finalize()
return await new Promise(resolve => {
output.on('end', () => { resolve() })
.on('close', () => { resolve() })
})
}
/**
* 连接服务器
*/
async connectSsh(): Promise<void> {
const m = this
if (m._client && m._connected) {
return
}
if (m._connectP) {
return await m._connectP
}
if (!m._connConfig) {
throw new Error('WlSsh2 connect config not set')
}
m._client = new SSH2Promise(m._connConfig)
m._connectP = m._client.connect()
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await m._connectP!
m._connected = true
m._connectP = undefined
}
/**
* 关闭连接
*/
async closeSsh() {
const m = this
if (m._client) {
return await m._client.close()
}
}
async exec(cmd: string) {
const m = this
await m.connectSsh()
return m._client?.exec(cmd)
}
async remoteMkDir(remoteDirPath: string): Promise<boolean> {
const m = this
await m.connectSsh()
const sftp = await m._client?.sftp()
let dirExists = true
try {
await sftp?.readdir(remoteDirPath)
} catch (err) {
dirExists = false
}
if (!dirExists) {
await m.exec(`mkdir -p ${remoteDirPath}`)
}
return !dirExists
}
/**
* 文件上传
*/
async sftpUpload(options: SftpUploadOptions): Promise<void> {
const m = this
await m.connectSsh()
const sftp = await m._client?.sftp()
const { localPath, remotePath, options: fastPutOptions } = options
return await sftp?.fastPut(localPath, remotePath, fastPutOptions)
}
async readDir(dirPath: string): Promise<Array<{filename: string}>> {
const m = this
await m.connectSsh()
const sftp = await m._client?.sftp()
return sftp?.readdir(dirPath)
}
}
export default DeployProcess
运行入口文件:deploy.ts
import DeployProcess from './DeployProcess'
import { connectConfig, deployConfig } from './config/config'
const deployProcess = new DeployProcess(connectConfig, deployConfig)
deployProcess.runDeploy().then(() => {
// finished
process.exit(0)
}).catch(err => {
console.error('Deploy err', err)
})
命令行调用示例
npx tsx src/build/deploy/deploy.ts