摘要
软件流水线能把程序员从繁琐的发布工作中解脱出来,但是跑在Windows IIS里的传统Web应用程序,用Docker的方式不是最方便的。本文详细描述如何用Windows的OpenSSH Server来上传网站后,用PowerShell创建和修改IIS的虚拟目录应用程序。
一、自动打包传统ASP.NET Web应用程序
1、Gitlab仓库中.gitlab-ci.yml中的配置
打包会员订单站点:
stage: publish-web
tags:
- runner-windows
before_script:
- . $BUILD_WEB_SCRIPT
script:
- echo "开始部署Passport……"
- cd $MSBUILD_PATH
- Build-Web $MSBUILD_PATH $CI_PROJECT_DIR "/****/*******.Orders.Web/********.Orders.Web.csproj" $YEE_CUSTOMER_ORDERS_WEB_OUTPUT_DIR
说明:
- before_script下的命令,是注册PowerShell的函数。因为服务器上的脚本是function。
- scripts的“Build-Web”就是PowerShell的函数名称,接受4个参数。
2、Windows Gitlab Runner中的PowerShell脚本
function Build-Web {
param (
[string]$msbuild_dir,
[string]$ci_project_dir,
[string]$project_path,
[string]$web_output_path
)
$project_file = $ci_project_dir + "\\" + $project_path
if(( $project_path -like "/*") -or ($project_path -like "\\*")){
$project_file = $ci_project_dir + $project_path
}
echo "项目文件"$project_file
echo "版本号"$version
echo "网站输出路径"$web_output_path
cd $ci_project_dir
COPY-ITEM -PATH 'x:/gitlab-runner/scripts/download.bat' -Destination .
.\download.bat
if ($LASTEXITCODE -ne 0) {
throw "下载Directory.Build.props及其相关文件时出错。"
}
dotnet nuget locals http-cache -c
$exePath = $msbuild_dir + "\\msbuild.exe"
if(Test-Path -Path $exePath){
}
else
{
throw "MSBuild.exe directory not found:" + $msbuild_dir
}
cd $msbuild_dir
.\msbuild -restore $project_file /v:m
if ($LASTEXITCODE -ne 0) {
throw "还原项目时出错:" + $project_file
}
cd X:\gitlab-runner\tools\binding-redirect-helper
.\BindingRedirect check --project:$project_file --save:true
if ($LASTEXITCODE -ne 0) {
throw "自动更新web.config中的绑定重定向时时出错:" + $project_file
}
cd $msbuild_dir
.\msbuild $project_file /t:ResolveReferences /t:Compile /p:configuration="Release" /t:_CopyWebApplication /p:WebProjectOutputDir=$web_output_path /p:OutputPath=$web_output_path"\bin" /v:m
Exit $LASTEXITCODE
}
说明:
- download.bat是配合Directory.Build.props技术,下载一堆相关文件。下载后清空NuGet的Http缓存;
- msbuild.exe先还原,再生成,分成了两步;
- /v:m是msbuild命令执行时给出最少的日志。需要调试的时候,用/v:diag;
- /t:_CopyWebApplication,需要在Web项目的.csproj文件中Import这个目标,在前文有详细说明;
- WebProjectOutputDir和OutputPath两个输出目录都要配置,一个管.aspx、.ascx和.css等资源文件,一个管dll。
4个参数的说明:
- $msbuild_dir msbuild.exe程序所在目录的路径;
- $ci_project_dir 这个地址是Gitlab内置的函数给的,就是Gitlab Runner从Gitlab服务器签出代码后的工作目录;
- $project_path,项目的.csproj文件,相对于仓库根目录的路径;
- $web_output_path的路径,一定要在解决方案之外。因为下一个负责上传的stage是拿不到这个stage执行时候目录下的文件的。
二、自动上传打包的网站到Web服务器
这里很复杂,需要几个准备工作
- Web服务器安装Open SSH Server;
- Web服务器配置好管理员账户的SSH公钥,并允许使用秘钥而不是密码来登入;
1、Gitlab仓库根目录.gitlab-ci.yml中的配置
上传会员订单网站到开发站:
stage: upload-web
tags:
- runner-windows
script:
- cd ${GITLAB_RUNNER_POWERSHELL_DIR}
- |
powershell.exe .\upload-web.ps1 `
-ssh_rsa_file $SSH_RSA_FILE `
-remote_username $DEV_TEST_SERVER_LOGIN_NAME `
-remote_server $DEV_TEST_SERVER `
-remote_root_dir_name loda `
-remote_site_name $REMOTE_WINDOWS_SITE_NAME `
-remote_windows_web_app_pool $REMOTE_WINDOWS_WEB_APP_POOL `
-web_app_name orders `
-web_output_path $YEE_CUSTOMER_ORDERS_WEB_OUTPUT_DIR `
-version_id $YEE_ORDERCENTER_VERSION_ID
说明:
- 上述命令是多行,换了行的。参数只能是美元符号开头直接跟参数名称,如果$YEE_ORDERCENTER_VERSION_ID换成${YEE_ORDERCENTER_VERSION_ID}这种大括号包裹参数名称,就会出错;
- 每一行里不能有双引号。比如倒数第3行,orders不能写成“orders”
参数说明:
- $SSH_RSA_FILE 用于SSH登入Web服务器的私钥的完整路径,这个私钥是放在Gitlab Runner服务器的。私钥所在的文件夹的权限要特殊配置:只有运行Gitlab Runner这个Windows服务器的账户有只读权限。否则SSH会报错;
- $DEV_TEST_SERVER_LOGIN_NAME 用于SSH登入Web服务器的用户名,比如Administrator;
- $DEV_TEST_SERVER Web服务器的DNS名称或IP地址;
- remote_root_dir_name 这个参数,是为了Web服务器安全。Web服务器上,我们的网站如果放在“D:\apps",这里传入loda,就是说网站根目录是“D:\apps\loda”;
- remote_site_name 这个参数,就是IIS中网站的名称,比如:Default Web Site。但是空格在命令从Gitlab Runner服务器传递到Web服务器会造成别的歧义,所以在Web服务器上的网站名字,实际上不能有空格;
- remote_windows_web_app_pool 这个就是Web服务器上,IIS应用程序池的名字,因为Gitlab CI传参数给Gitlab Runner的PowerShell,又从这个PowerShell执行SSH到Web服务器,参数传递中空格有特别含义,所以这个应用程序池的名字和网站名字一样:不能有空格;
- web_app_name 这个名字,就是IIS网站根目录下的名字,会出现在URL里,所以不能有小数点不能有空格,只能是英文字母、数字和连接符;
- web_output_path 网站打包后,放在Gitlab Runner的路径,这个路径是绝对路径,不能实在stage工作目录内部;
- version_id 我们NuGet的包的版本号都是小数点分隔的,但是作为Web路径的时候,小数点有特别的含义,所以我们的函数收到带小数点的版本号后,会把小数点替换成下划线。
2、Windows Gitlab Runner服务器上被调用的PowerShell脚本
下边的脚本,最值得说的就是sshCmd。
因为这个脚本在Gitlab Runner服务器上,被Gitlab Runner调用以后,要SSH到Web服务器去执行命令。
# 设置环境变量以支持UTF-8编码
param (
[Parameter(Mandatory=$true)]
[string]$ssh_rsa_file,
[Parameter(Mandatory=$true)]
[string]$web_output_path,
[Parameter(Mandatory=$true)]
[string]$remote_username,
[Parameter(Mandatory=$true)]
[string]$remote_server,
[Parameter(Mandatory=$true)]
[string]$remote_root_dir_name,
[Parameter(Mandatory=$true)]
[string]$remote_site_name,
[Parameter(Mandatory=$true)]
[string]$remote_windows_web_app_pool,
[Parameter(Mandatory=$true)]
[string]$web_app_name,
[Parameter(Mandatory=$true)]
[string]$version_id
)
chcp 65001
$version_id = $version_id.Replace('.','_')
# 确保所有参数值中的特殊字符被适当转义,特别是对于包含在路径或字符串中的双引号
$escapedSiteName = $remote_site_name -replace "'", "''"
$escapedPool = $remote_windows_web_app_pool -replace "'", "''"
$escapedAppName = $web_app_name -replace "'", "''"
$escapedVersion = $version_id -replace "'", "''"
$remote_script_path = "c:\for-gitlab-runner\Setup-LodaWebApp.ps1"
# 构建远程脚本调用的参数字符串,保持简单并确保参数间以空格分隔
$remoteParams = "-remote_site_name '$escapedSiteName' -remote_windows_web_app_pool '$escapedPool' -web_app_name '$escapedAppName' -version_id '$escapedVersion'"
# 构建完整的SSH命令,注意引号的正确使用和转义
$sshCmd = "ssh -i `"$ssh_rsa_file`" -o ""StrictHostKeyChecking=no"" -v `"$remote_username@$remote_server`" 'powershell.exe -ExecutionPolicy Bypass -File `"$remote_script_path`" $remoteParams'"
# 注意:$remote_script_path 应替换为实际的远程脚本路径,如"c:\for-gitlab-runner\Setup-LodaWebApp.ps1"
# 打印命令以供调试
Write-Host "Executing Command: $sshCmd"
# 执行SSH命令
Invoke-Expression $sshCmd
if ($LASTEXITCODE -ne 0) {
if ($Errors.Count -gt 0) {
Write-Host "清空远程目录时出错:" $Errors[0].Exception.Message
Write-Host "错误信息:" $Errors[0].Exception.Message
Write-Host "堆栈跟踪:" $Errors[0].Exception.StackTrace
}
else {
Write-Host "没有错误信息记录。"
}
}
$remote_dir = "C:\" + $remote_root_dir_name + "\" + $web_app_name + "\" + $version_id
scp -i $ssh_rsa_file -o StrictHostKeyChecking=no -rv ${web_output_path}"\*" ${remote_username}@${remote_server}:$remote_dir
Exit $LASTEXITCODE
3、Web服务器上,被Gitlab Runner的PowerShell调用的PowerShell脚本
看这个标题就拗口,理解后就很简单,这个脚本的作用是:
- 为Web程序创建新目录,这个目录的名字,是带了version_id的。因为web程序在IIS里运行的时候,dll文件无法被覆盖,所以要准备新目录;
- 在网站根目录下,以这个新目录创建应用程序。如果应用程序已经存在,则NEW-WebApplication的最后一个参数-Force,就强制把应用程序物理路径给修改到新目录
下边是脚本内容:
param(
[Parameter(Mandatory=$true)]
[string]$remote_site_name,
[Parameter(Mandatory=$true)]
[string]$remote_windows_web_app_pool,
[Parameter(Mandatory=$true)]
[string]$web_app_name,
[Parameter(Mandatory=$true)]
[string]$version_id
)
chcp 65001
Write-Host "已°?绑㨮定¡§的Ì?参?数ºy及¡ã其?值¦Ì:"
$PSBoundParameters.GetEnumerator() | ForEach-Object {
Write-Host "$($_.Key): $($_.Value)"
}
$version_id = $version_id.Replace('.','_')
$AppPhysicalPath = "C:\loda\" + ${web_app_name} + "\" + $version_id
New-Item -Path $AppPhysicalPath -ItemType Directory -Force
# 定¡§义°?IIS PowerShell模¡ê块¨¦路¡¤径?,ê?根¨´据Y实º¦Ì际¨º情¨¦况?调Ì¡Â整?
$IISModulePath = "C:\Windows\System32\WindowsPowerShell\v1.0\Modules\WebAdministration\WebAdministration.psd1"
if (!(Test-Path $IISModulePath)) {
Write-Error "IIS Administration PowerShell module not found at $IISModulePath"
exit 1
}
Import-Module $IISModulePath
# 确¨¡¤保À¡ê应®|用®?池?存ä?在¨²
if (!(Get-ChildItem IIS:\AppPools | Where-Object { $_.Name -eq $remote_windows_web_app_pool })) {
Write-Error "Application Pool '$remote_windows_web_app_pool' does not exist."
exit 1
}
# 确¨¡¤保À¡ê站?点Ì?存ä?在¨²
$sitePath = "IIS:\Sites\$remote_site_name"
if (!(Test-Path $sitePath)) {
Write-Error "Site '$remote_site_name' does not exist."
exit 1
}
# 创ä¡ä建¡§应®|用®?程¨¬序¨°
Write-Host "Creating application '$web_app_name' under site '$remote_site_name' with physical path '$AppPhysicalPath'"
NEW-WebApplication -Name $web_app_name -Site $remote_site_name -ApplicationPool $remote_windows_web_app_pool -PhysicalPath $AppPhysicalPath -Force
if($LASTEXITCODE -ne 0)
{
Write-Error "Failed to create the application."
exit $LASTEXITCODE
}
# 确¨¡¤认¨?操¨´作Á¡Â成¨¦功|
$appPath = Join-Path $sitePath $web_app_name
if (Test-Path $appPath) {
Write-Host "Application created successfully at '$appPath'."
} else {
Write-Error "Failed to create the application."
}
exit 0
参数说明
前两个参数的remote_其实要删除才合理。因为这里已经是目的地了,是别人的remote。
黑夜里不停折腾的代码行者。