You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

418 lines
17 KiB
C#

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
//
// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
//
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
using System.IO.Compression;
using System.Net;
using System.Security.Cryptography;
namespace Admin.NET.Core.Service;
/// <summary>
/// 系统更新管理服务 🧩
/// </summary>
[ApiDescriptionSettings(Order = 390)]
public class SysUpdateService : IDynamicApiController, ITransient
{
private readonly SysCacheService _sysCacheService;
private readonly CDConfigOptions _cdConfigOptions;
public SysUpdateService(IOptions<CDConfigOptions> giteeOptions, SysCacheService sysCacheService)
{
_cdConfigOptions = giteeOptions.Value;
_sysCacheService = sysCacheService;
}
/// <summary>
/// 备份列表
/// </summary>
/// <returns></returns>
[DisplayName("备份列表")]
[ApiDescriptionSettings(Name = "List"), HttpPost]
public Task<List<BackupOutput>> List()
{
const string backendDir = "Admin.NET";
var rootPath = Path.GetFullPath(Path.Combine(_cdConfigOptions.BackendOutput, ".."));
return Task.FromResult(Directory.GetFiles(rootPath, backendDir + "*.zip", SearchOption.TopDirectoryOnly)
.Select(filePath =>
{
var file = new FileInfo(filePath);
return new BackupOutput
{
CreateTime = file.CreationTime,
FilePath = filePath,
FileName = file.Name
};
})
.OrderByDescending(u => u.CreateTime)
.ToList());
}
/// <summary>
/// 还原
/// </summary>
/// <returns></returns>
[DisplayName("还原")]
[ApiDescriptionSettings(Name = "Restore"), HttpPost]
public async Task Restore(RestoreInput input)
{
// 检查参数
CheckConfig();
try
{
var file = (await List()).FirstOrDefault(u => u.FileName.EqualIgnoreCase(input.FileName));
if (file == null)
{
PrintfLog("文件不存在...");
return;
}
PrintfLog("正在还原...");
using ZipArchive archive = new(File.OpenRead(file.FilePath), ZipArchiveMode.Read, leaveOpen: false);
archive.ExtractToDirectory(_cdConfigOptions.BackendOutput, true);
PrintfLog("还原成功...");
}
catch (Exception ex)
{
PrintfLog("发生异常:" + ex.Message);
throw;
}
}
/// <summary>
/// 从远端更新系统
/// </summary>
/// <returns></returns>
[DisplayName("系统更新")]
[ApiDescriptionSettings(Name = "Update"), HttpPost]
public async Task Update()
{
var originColor = Console.ForegroundColor;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"【{DateTime.Now}】从远端仓库部署项目");
try
{
PrintfLog("----------------------------从远端仓库部署项目-开始----------------------------");
// 检查参数
CheckConfig();
// 检查操作间隔
if (_cdConfigOptions.UpdateInterval > 0)
{
if (_sysCacheService.Get<bool>(CacheConst.KeySysUpdateInterval)) throw Oops.Oh("请勿频繁操作");
_sysCacheService.Set(CacheConst.KeySysUpdateInterval, true, TimeSpan.FromMinutes(_cdConfigOptions.UpdateInterval));
}
PrintfLog($"客户端host{App.HttpContext.Request.Host}");
PrintfLog($"客户端IP{App.HttpContext.GetRemoteIpAddressToIPv4(true)}");
PrintfLog($"仓库地址https://gitee.com/{_cdConfigOptions.Owner}/{_cdConfigOptions.Repo}.git");
PrintfLog($"仓库分支:{_cdConfigOptions.Branch}");
// 获取解压后的根目录
var rootPath = Path.GetFullPath(Path.Combine(_cdConfigOptions.BackendOutput, ".."));
var tempDir = Path.Combine(rootPath, $"{_cdConfigOptions.Repo}-{_cdConfigOptions.Branch}");
PrintfLog("清理旧文件...");
FileHelper.TryDelete(tempDir);
PrintfLog("拉取远端代码...");
var stream = await GiteeHelper.DownloadRepoZip(_cdConfigOptions.Owner, _cdConfigOptions.Repo,
_cdConfigOptions.AccessToken, _cdConfigOptions.Branch);
PrintfLog("文件包解压...");
using ZipArchive archive = new(stream, ZipArchiveMode.Read, leaveOpen: false);
archive.ExtractToDirectory(rootPath);
// 项目目录
var backendDir = "Admin.NET"; // 后端根目录
var entryProjectName = "Admin.NET.Web.Entry"; // 启动项目目录
var tempOutput = Path.Combine(rootPath, $"{_cdConfigOptions.Repo}_temp");
PrintfLog("编译项目...");
PrintfLog($"发布版本:{_cdConfigOptions.Publish.Configuration}");
PrintfLog($"目标框架:{_cdConfigOptions.Publish.TargetFramework}");
PrintfLog($"运行环境:{_cdConfigOptions.Publish.RuntimeIdentifier}");
var option = _cdConfigOptions.Publish;
var adminNetDir = Path.Combine(tempDir, backendDir);
var args = $"publish \"{entryProjectName}\" -c {option.Configuration} -f {option.TargetFramework} -r {option.RuntimeIdentifier} --output \"{tempOutput}\"";
await RunCommandAsync("dotnet", args, adminNetDir);
PrintfLog("复制 wwwroot 目录...");
var wwwrootDir = Path.Combine(adminNetDir, entryProjectName, "wwwroot");
FileHelper.CopyDirectory(wwwrootDir, Path.Combine(tempOutput, "wwwroot"), true);
// 删除排除文件
foreach (var filePath in (_cdConfigOptions.ExcludeFiles ?? new()).SelectMany(file => Directory.GetFiles(tempOutput, file, SearchOption.TopDirectoryOnly)))
{
PrintfLog($"排除文件:{filePath}");
FileHelper.TryDelete(filePath);
}
PrintfLog("备份原项目文件...");
string backupPath = Path.Combine(rootPath, $"{_cdConfigOptions.Repo}_{DateTime.Now:yyyy_MM_dd}.zip");
if (File.Exists(backupPath)) File.Delete(backupPath);
ZipFile.CreateFromDirectory(_cdConfigOptions.BackendOutput, backupPath);
// 将临时文件移动到正式目录
FileHelper.CopyDirectory(tempOutput, _cdConfigOptions.BackendOutput, true);
PrintfLog("清理文件...");
FileHelper.TryDelete(tempOutput);
FileHelper.TryDelete(tempDir);
if (_cdConfigOptions.BackupCount > 0)
{
var fileList = await List();
if (fileList.Count > _cdConfigOptions.BackupCount)
PrintfLog("清除多余的备份文件...");
while (fileList.Count > _cdConfigOptions.BackupCount)
{
var last = fileList.Last();
FileHelper.TryDelete(last.FilePath);
fileList.Remove(last);
}
}
PrintfLog("重启项目后生效...");
}
catch (Exception ex)
{
PrintfLog("发生异常:" + ex.Message);
throw;
}
finally
{
PrintfLog("----------------------------从远端仓库部署项目-结束----------------------------");
Console.ForegroundColor = originColor;
}
}
/// <summary>
/// 仓库WebHook接口
/// </summary>
/// <returns></returns>
[AllowAnonymous]
[DisplayName("仓库WebHook接口")]
[ApiDescriptionSettings(Name = "WebHook"), HttpPost]
public async Task WebHook(Dictionary<string, object> input)
{
if (!_cdConfigOptions.Enabled) throw Oops.Oh("未启用持续部署功能");
PrintfLog("----------------------------收到WebHook请求-开始----------------------------");
try
{
// 获取请求头信息
var even = App.HttpContext.Request.Headers.FirstOrDefault(u => u.Key == "X-Gitee-Event").Value
.FirstOrDefault();
var ua = App.HttpContext.Request.Headers.FirstOrDefault(u => u.Key == "User-Agent").Value.FirstOrDefault();
var timestamp = input.GetValueOrDefault("timestamp")?.ToString();
var token = input.GetValueOrDefault("sign")?.ToString();
PrintfLog("User-Agent" + ua);
PrintfLog("Gitee-Event" + even);
PrintfLog("Gitee-Token" + token);
PrintfLog("Gitee-Timestamp" + timestamp);
PrintfLog("开始验签...");
var secret = GetWebHookKey();
var stringToSign = $"{timestamp}\n{secret}";
using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var signData = mac.ComputeHash(Encoding.UTF8.GetBytes(stringToSign));
var encodedSignData = Convert.ToBase64String(signData);
var calculatedSignature = WebUtility.UrlEncode(encodedSignData);
if (calculatedSignature != token) throw Oops.Oh("非法签名");
PrintfLog("验签成功...");
var hookName = input.GetValueOrDefault("hook_name") as string;
PrintfLog("Hook-Name" + hookName);
switch (hookName)
{
// 提交修改
case "push_hooks":
{
var commitList = input.GetValueOrDefault("commits")?.Adapt<List<Dictionary<string, object>>>() ?? new();
foreach (var commit in commitList)
{
var author = commit.GetValueOrDefault("author")?.Adapt<Dictionary<string, object>>();
PrintfLog("Commit-Message" + commit.GetValueOrDefault("message"));
PrintfLog("Commit-Time" + commit.GetValueOrDefault("timestamp"));
PrintfLog("Commit-Author" + author?.GetValueOrDefault("username"));
PrintfLog("Modified-List" + author?.GetValueOrDefault("modified")?.Adapt<List<string>>().Join());
PrintfLog("----------------------------------------------------------");
}
break;
}
// 合并 Pull Request
case "merge_request_hooks":
{
var pull = input.GetValueOrDefault("pull_request")?.Adapt<Dictionary<string, object>>();
var user = pull?.GetValueOrDefault("user")?.Adapt<Dictionary<string, object>>();
PrintfLog("Pull-Request-Title" + pull?.GetValueOrDefault("message"));
PrintfLog("Pull-Request-Time" + pull?.GetValueOrDefault("created_at"));
PrintfLog("Pull-Request-Author" + user?.GetValueOrDefault("username"));
PrintfLog("Pull-Request-Body" + pull?.GetValueOrDefault("body"));
break;
}
// 新的issue
case "issue_hooks":
{
var issue = input.GetValueOrDefault("issue")?.Adapt<Dictionary<string, object>>();
var user = issue?.GetValueOrDefault("user")?.Adapt<Dictionary<string, object>>();
var labelList = issue?.GetValueOrDefault("labels")?.Adapt<List<Dictionary<string, object>>>();
PrintfLog("Issue-UserName" + user?.GetValueOrDefault("username"));
PrintfLog("Issue-Labels" + labelList?.Select(u => u.GetValueOrDefault("name")).Join());
PrintfLog("Issue-Title" + issue?.GetValueOrDefault("title"));
PrintfLog("Issue-Time" + issue?.GetValueOrDefault("created_at"));
PrintfLog("Issue-Body" + issue?.GetValueOrDefault("body"));
return;
}
// 评论
case "note_hooks":
{
var comment = input.GetValueOrDefault("comment")?.Adapt<Dictionary<string, object>>();
var user = input.GetValueOrDefault("user")?.Adapt<Dictionary<string, object>>();
PrintfLog("comment-UserName" + user?.GetValueOrDefault("username"));
PrintfLog("comment-Time" + comment?.GetValueOrDefault("created_at"));
PrintfLog("comment-Content" + comment?.GetValueOrDefault("body"));
return;
}
default:
return;
}
var updateInterval = _cdConfigOptions.UpdateInterval;
try
{
_cdConfigOptions.UpdateInterval = 0;
await Update();
}
finally
{
_cdConfigOptions.UpdateInterval = updateInterval;
}
}
finally
{
PrintfLog("----------------------------收到WebHook请求-结束----------------------------");
}
}
/// <summary>
/// 获取WebHook接口密钥
/// </summary>
/// <returns></returns>
[DisplayName("获取WebHook接口密钥")]
[ApiDescriptionSettings(Name = "WebHookKey"), HttpGet]
public string GetWebHookKey()
{
return CryptogramUtil.Encrypt(_cdConfigOptions.AccessToken);
}
/// <summary>
/// 获取日志列表
/// </summary>
/// <returns></returns>
[DisplayName("获取日志列表")]
[ApiDescriptionSettings(Name = "Logs"), HttpGet]
public List<string> LogList()
{
return _sysCacheService.Get<List<string>>(CacheConst.KeySysUpdateLog) ?? new();
}
/// <summary>
/// 清空日志
/// </summary>
/// <returns></returns>
[DisplayName("清空日志")]
[ApiDescriptionSettings(Name = "Clear"), HttpGet]
public void ClearLog()
{
_sysCacheService.Remove(CacheConst.KeySysUpdateLog);
}
/// <summary>
/// 检查参数
/// </summary>
/// <returns></returns>
private void CheckConfig()
{
PrintfLog("检查CD配置参数...");
if (_cdConfigOptions == null) throw Oops.Oh("CDConfig配置不能为空");
if (string.IsNullOrWhiteSpace(_cdConfigOptions.Owner)) throw Oops.Oh("仓库用户名不能为空");
if (string.IsNullOrWhiteSpace(_cdConfigOptions.Repo)) throw Oops.Oh("仓库名不能为空");
// if (string.IsNullOrWhiteSpace(_cdConfigOptions.Branch)) throw Oops.Oh("分支名不能为空");
if (string.IsNullOrWhiteSpace(_cdConfigOptions.AccessToken)) throw Oops.Oh("授权信息不能为空");
if (string.IsNullOrWhiteSpace(_cdConfigOptions.BackendOutput)) throw Oops.Oh("部署目录不能为空");
if (_cdConfigOptions.Publish == null) throw Oops.Oh("编译配置不能为空");
if (string.IsNullOrWhiteSpace(_cdConfigOptions.Publish.Configuration)) throw Oops.Oh("运行环境编译配置不能为空");
if (string.IsNullOrWhiteSpace(_cdConfigOptions.Publish.TargetFramework)) throw Oops.Oh(".NET版本编译配置不能为空");
if (string.IsNullOrWhiteSpace(_cdConfigOptions.Publish.RuntimeIdentifier)) throw Oops.Oh("运行平台配置不能为空");
}
/// <summary>
/// 打印日志
/// </summary>
/// <param name="message"></param>
private void PrintfLog(string message)
{
var logList = _sysCacheService.Get<List<string>>(CacheConst.KeySysUpdateLog) ?? new();
var content = $"【{DateTime.Now}】 {message}";
Console.WriteLine(content);
logList.Add(content);
_sysCacheService.Set(CacheConst.KeySysUpdateLog, logList);
}
/// <summary>
/// 执行命令
/// </summary>
/// <param name="command">命令</param>
/// <param name="arguments">参数</param>
/// <param name="workingDirectory">工作目录</param>
private async Task RunCommandAsync(string command, string arguments, string workingDirectory)
{
var processStartInfo = new ProcessStartInfo
{
FileName = command,
Arguments = arguments,
WorkingDirectory = workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = new Process();
process.StartInfo = processStartInfo;
process.Start();
while (!process.StandardOutput.EndOfStream)
{
string line = await process.StandardOutput.ReadLineAsync();
if (string.IsNullOrEmpty(line)) continue;
PrintfLog(line.Trim());
}
await process.WaitForExitAsync();
}
}