有风塘主
发布于 2024-10-26 / 55 阅读
0
0

使用Ssh2-Promise进行一键部署

使用Ssh2-Promise进行一键部署

目的

在前端项目中,项目开发完成后的构建、部署是个问题,在不使用dockerjinkins等工具的情况下,实现代码的自动部署或一键部署

常规方法

利用Git将代码clone到目标服务器上,然后在目标服务器上运行构建命令,再将构建结果文件移到 部署目录

  • 需要登录服务器
  • 服务器环境依赖NodeJS,依赖Git,且前端构建也要求服务器的硬件配置不能太低
  • 服务器需要存储源代码文件,需要安装项目的依赖包。

新方法

使用Ssh2-promise通过代码连接服务器,将打包好的代码文件进行上传和不是

基本原理

在某台存放有项目开发代码的电脑上,执行项目的构建,生成dist/**/*等项目运行文件
编写node程序,使用archiver包进行文件的压缩打包
使用ssh2-promisesftpexec等工具上传压缩后的运行文件,并远程执行服务器命令,完成项目运行文件在服务器上的解压缩、部署等操作。

代码

操作类: 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

评论