mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-07-01 15:32:48 +08:00
feat(stack): 新增 stack 模式管理功能
- 新增 `php think admin:stack:mode` 命令,支持 list/use/current/rollback 操作 - 新增 StackModeService 服务,负责模式切换、备份与回滚逻辑 - 在 source/stack/ 目录下添加 default、full、base-build 三种模式的配置文件 - 更新 UlthonAdminService 以注册新的命令行工具
This commit is contained in:
9
app/common/command/admin/stack/AdminStackMode.php
Normal file
9
app/common/command/admin/stack/AdminStackMode.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\command\admin\stack;
|
||||
|
||||
use base\common\command\admin\stack\AdminStackModeBase;
|
||||
|
||||
class AdminStackMode extends AdminStackModeBase
|
||||
{
|
||||
}
|
||||
9
app/common/service/stack/StackModeService.php
Normal file
9
app/common/service/stack/StackModeService.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service\stack;
|
||||
|
||||
use base\common\service\stack\StackModeServiceBase;
|
||||
|
||||
class StackModeService extends StackModeServiceBase
|
||||
{
|
||||
}
|
||||
135
extend/base/common/command/admin/stack/AdminStackModeBase.php
Normal file
135
extend/base/common/command/admin/stack/AdminStackModeBase.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace base\common\command\admin\stack;
|
||||
|
||||
use app\common\console\Command;
|
||||
use app\common\service\stack\StackModeService;
|
||||
use RuntimeException;
|
||||
use think\console\Input;
|
||||
use think\console\input\Argument;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
class AdminStackModeBase extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
parent::configure();
|
||||
|
||||
$this->setName('admin:stack:mode')
|
||||
->setDescription('管理 Stack 模式:list/use/current/rollback')
|
||||
->addArgument('action', Argument::OPTIONAL, '操作:list|use|current|rollback', 'list')
|
||||
->addArgument('value', Argument::OPTIONAL, 'mode 或 backup_id')
|
||||
->addOption('force', 'f', Option::VALUE_NONE, '跳过确认步骤');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$action = strtolower((string)$input->getArgument('action'));
|
||||
$value = (string)$input->getArgument('value');
|
||||
$force = (bool)$input->getOption('force');
|
||||
|
||||
try {
|
||||
/** @var StackModeService $service */
|
||||
$service = app(StackModeService::class);
|
||||
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
$this->handleList($service, $output);
|
||||
break;
|
||||
case 'current':
|
||||
$this->handleCurrent($service, $output);
|
||||
break;
|
||||
case 'use':
|
||||
$this->handleUse($service, $input, $output, $value, $force);
|
||||
break;
|
||||
case 'rollback':
|
||||
$this->handleRollback($service, $input, $output, $value, $force);
|
||||
break;
|
||||
default:
|
||||
throw new RuntimeException('不支持的 action:' . $action);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$output->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
protected function handleList(StackModeService $service, Output $output): void
|
||||
{
|
||||
$modes = $service->listModes();
|
||||
if (empty($modes)) {
|
||||
$output->warning('未发现可用模式');
|
||||
return;
|
||||
}
|
||||
|
||||
$output->writeln('可用模式:');
|
||||
foreach ($modes as $item) {
|
||||
$mode = (string)$item['mode'];
|
||||
$desc = (string)$item['description'];
|
||||
$authorOnly = (bool)$item['author_only'] ? 'yes' : 'no';
|
||||
$output->writeln("- {$mode} | author_only={$authorOnly} | {$desc}");
|
||||
}
|
||||
}
|
||||
|
||||
protected function handleCurrent(StackModeService $service, Output $output): void
|
||||
{
|
||||
$state = $service->getCurrentState();
|
||||
$output->writeln('当前模式:' . ((string)$state['mode'] === '' ? '未记录' : (string)$state['mode']));
|
||||
$output->writeln('切换时间:' . ((string)$state['switched_at'] === '' ? '未记录' : (string)$state['switched_at']));
|
||||
$output->writeln('备份ID:' . ((string)$state['backup_id'] === '' ? '未记录' : (string)$state['backup_id']));
|
||||
}
|
||||
|
||||
protected function handleUse(
|
||||
StackModeService $service,
|
||||
Input $input,
|
||||
Output $output,
|
||||
string $mode,
|
||||
bool $force
|
||||
): void {
|
||||
$mode = trim($mode);
|
||||
if ($mode === '') {
|
||||
throw new RuntimeException('请提供 mode,例如:admin:stack:mode use base-build');
|
||||
}
|
||||
|
||||
$planData = $service->getModePlan($mode);
|
||||
$output->writeln("将切换到模式:{$mode}");
|
||||
foreach ($planData['plan'] as $item) {
|
||||
$file = (string)$item['file'];
|
||||
$sourceMode = (string)$item['source_mode'];
|
||||
$output->writeln("- {$file} <= {$sourceMode}");
|
||||
}
|
||||
|
||||
if (!$force && !$output->confirm($input, '确认执行覆盖切换?', true)) {
|
||||
$output->warning('已取消');
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $service->applyMode($mode, get_current_user() ?: 'cli');
|
||||
$output->info('模式切换完成');
|
||||
$output->writeln('mode=' . $result['mode']);
|
||||
$output->writeln('backup_id=' . $result['backup_id']);
|
||||
}
|
||||
|
||||
protected function handleRollback(
|
||||
StackModeService $service,
|
||||
Input $input,
|
||||
Output $output,
|
||||
string $backupId,
|
||||
bool $force
|
||||
): void {
|
||||
$backupId = trim($backupId);
|
||||
if ($backupId === '') {
|
||||
throw new RuntimeException('请提供 backup_id,例如:admin:stack:mode rollback 20260424120000-abcd1234');
|
||||
}
|
||||
|
||||
if (!$force && !$output->confirm($input, "确认回滚到备份 {$backupId}?", true)) {
|
||||
$output->warning('已取消');
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $service->rollback($backupId, get_current_user() ?: 'cli');
|
||||
$output->info('回滚完成');
|
||||
$output->writeln('backup_id=' . $result['backup_id']);
|
||||
$output->writeln('restored_files=' . count($result['restored_files']));
|
||||
}
|
||||
}
|
||||
371
extend/base/common/service/stack/StackModeServiceBase.php
Normal file
371
extend/base/common/service/stack/StackModeServiceBase.php
Normal file
@@ -0,0 +1,371 @@
|
||||
<?php
|
||||
|
||||
namespace base\common\service\stack;
|
||||
|
||||
use RuntimeException;
|
||||
use think\facade\App;
|
||||
|
||||
class StackModeServiceBase
|
||||
{
|
||||
protected string $rootPath;
|
||||
protected string $stackRoot;
|
||||
protected string $stackConfigPath;
|
||||
protected string $backupRoot;
|
||||
protected string $stateFile;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->rootPath = rtrim(App::getRootPath(), "\\/ \t\n\r\0\x0B");
|
||||
$this->stackRoot = $this->joinPath($this->rootPath, 'source', 'stack');
|
||||
$this->stackConfigPath = $this->joinPath($this->stackRoot, 'stack.json');
|
||||
$this->backupRoot = $this->joinPath($this->rootPath, 'runtime', 'agents', 'stack-backup');
|
||||
$this->stateFile = $this->joinPath($this->rootPath, 'runtime', 'agents', 'stack-current.json');
|
||||
}
|
||||
|
||||
public function listModes(): array
|
||||
{
|
||||
$config = $this->loadConfig();
|
||||
$modes = [];
|
||||
foreach ($config['modes'] as $mode => $meta) {
|
||||
$modes[] = [
|
||||
'mode' => $mode,
|
||||
'description' => (string)($meta['description'] ?? ''),
|
||||
'author_only' => (bool)($meta['author_only'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
return $modes;
|
||||
}
|
||||
|
||||
public function getCurrentState(): array
|
||||
{
|
||||
if (!is_file($this->stateFile)) {
|
||||
return [
|
||||
'mode' => null,
|
||||
'switched_at' => null,
|
||||
'backup_id' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$data = $this->readJsonFile($this->stateFile);
|
||||
return [
|
||||
'mode' => (string)($data['mode'] ?? ''),
|
||||
'switched_at' => (string)($data['switched_at'] ?? ''),
|
||||
'backup_id' => (string)($data['backup_id'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
public function getModePlan(string $mode): array
|
||||
{
|
||||
$mode = trim($mode);
|
||||
if ($mode === '') {
|
||||
throw new RuntimeException('模式不能为空');
|
||||
}
|
||||
|
||||
$config = $this->loadConfig();
|
||||
$modes = $config['modes'];
|
||||
if (!isset($modes[$mode])) {
|
||||
throw new RuntimeException("模式不存在:{$mode}");
|
||||
}
|
||||
|
||||
$defaultMode = $config['default_mode'];
|
||||
$managedFiles = $config['managed_files'];
|
||||
|
||||
$plan = [];
|
||||
foreach ($managedFiles as $relativePath) {
|
||||
$modeSource = $this->resolveModeFile($mode, $relativePath);
|
||||
if ($modeSource !== null) {
|
||||
$plan[] = [
|
||||
'file' => $relativePath,
|
||||
'source_mode' => $mode,
|
||||
'source_path' => $modeSource,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$defaultSource = $this->resolveModeFile($defaultMode, $relativePath);
|
||||
if ($defaultSource === null) {
|
||||
throw new RuntimeException("默认模式缺少文件:{$relativePath}");
|
||||
}
|
||||
|
||||
$plan[] = [
|
||||
'file' => $relativePath,
|
||||
'source_mode' => $defaultMode,
|
||||
'source_path' => $defaultSource,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'mode' => $mode,
|
||||
'default_mode' => $defaultMode,
|
||||
'managed_files' => $managedFiles,
|
||||
'plan' => $plan,
|
||||
];
|
||||
}
|
||||
|
||||
public function applyMode(string $mode, string $operator = 'system'): array
|
||||
{
|
||||
$planData = $this->getModePlan($mode);
|
||||
$plan = $planData['plan'];
|
||||
|
||||
$this->ensureDir($this->backupRoot);
|
||||
$backupId = date('YmdHis') . '-' . substr(md5((string)microtime(true)), 0, 8);
|
||||
$backupDir = $this->joinPath($this->backupRoot, $backupId);
|
||||
$backupFilesDir = $this->joinPath($backupDir, 'files');
|
||||
$this->ensureDir($backupFilesDir);
|
||||
|
||||
$currentState = $this->getCurrentState();
|
||||
$filesMeta = [];
|
||||
|
||||
foreach ($plan as $item) {
|
||||
$relativePath = $item['file'];
|
||||
$targetPath = $this->toRootPath($relativePath);
|
||||
$backupFilePath = $this->joinPath($backupFilesDir, $this->toSystemPath($relativePath));
|
||||
|
||||
$exists = is_file($targetPath);
|
||||
if ($exists) {
|
||||
$this->ensureDir(dirname($backupFilePath));
|
||||
$content = file_get_contents($targetPath);
|
||||
if ($content === false) {
|
||||
throw new RuntimeException("读取目标文件失败:{$relativePath}");
|
||||
}
|
||||
file_put_contents($backupFilePath, $content);
|
||||
}
|
||||
|
||||
$filesMeta[] = [
|
||||
'file' => $relativePath,
|
||||
'existed' => $exists,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($plan as $item) {
|
||||
$relativePath = $item['file'];
|
||||
$targetPath = $this->toRootPath($relativePath);
|
||||
$sourcePath = $item['source_path'];
|
||||
|
||||
$content = file_get_contents($sourcePath);
|
||||
if ($content === false) {
|
||||
throw new RuntimeException("读取模式文件失败:{$relativePath}");
|
||||
}
|
||||
|
||||
$this->ensureDir(dirname($targetPath));
|
||||
file_put_contents($targetPath, $content);
|
||||
}
|
||||
|
||||
$meta = [
|
||||
'backup_id' => $backupId,
|
||||
'mode' => $mode,
|
||||
'operator' => $operator,
|
||||
'created_at' => date('c'),
|
||||
'files' => $filesMeta,
|
||||
'prev_state' => $currentState,
|
||||
];
|
||||
$this->writeJsonFile($this->joinPath($backupDir, 'meta.json'), $meta);
|
||||
|
||||
$newState = [
|
||||
'mode' => $mode,
|
||||
'switched_at' => date('c'),
|
||||
'backup_id' => $backupId,
|
||||
'managed_files' => array_column($plan, 'file'),
|
||||
];
|
||||
$this->writeJsonFile($this->stateFile, $newState);
|
||||
|
||||
return [
|
||||
'mode' => $mode,
|
||||
'backup_id' => $backupId,
|
||||
'applied_files' => array_column($plan, 'file'),
|
||||
];
|
||||
}
|
||||
|
||||
public function rollback(string $backupId, string $operator = 'system'): array
|
||||
{
|
||||
$backupId = trim($backupId);
|
||||
if ($backupId === '') {
|
||||
throw new RuntimeException('backup_id 不能为空');
|
||||
}
|
||||
|
||||
$backupDir = $this->joinPath($this->backupRoot, $backupId);
|
||||
$metaPath = $this->joinPath($backupDir, 'meta.json');
|
||||
if (!is_file($metaPath)) {
|
||||
throw new RuntimeException("备份不存在:{$backupId}");
|
||||
}
|
||||
|
||||
$meta = $this->readJsonFile($metaPath);
|
||||
$files = $meta['files'] ?? [];
|
||||
if (!is_array($files)) {
|
||||
throw new RuntimeException("备份元数据损坏:{$backupId}");
|
||||
}
|
||||
|
||||
$backupFilesDir = $this->joinPath($backupDir, 'files');
|
||||
foreach ($files as $item) {
|
||||
$relativePath = (string)($item['file'] ?? '');
|
||||
if ($relativePath === '') {
|
||||
continue;
|
||||
}
|
||||
$relativePath = $this->normalizeRelativePath($relativePath);
|
||||
$targetPath = $this->toRootPath($relativePath);
|
||||
$existed = (bool)($item['existed'] ?? false);
|
||||
$backupFilePath = $this->joinPath($backupFilesDir, $this->toSystemPath($relativePath));
|
||||
|
||||
if ($existed) {
|
||||
if (!is_file($backupFilePath)) {
|
||||
throw new RuntimeException("备份文件缺失:{$relativePath}");
|
||||
}
|
||||
$content = file_get_contents($backupFilePath);
|
||||
if ($content === false) {
|
||||
throw new RuntimeException("读取备份文件失败:{$relativePath}");
|
||||
}
|
||||
$this->ensureDir(dirname($targetPath));
|
||||
file_put_contents($targetPath, $content);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_file($targetPath)) {
|
||||
@unlink($targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
$currentState = $this->getCurrentState();
|
||||
$restoreState = $meta['prev_state'] ?? [];
|
||||
$this->writeJsonFile($this->stateFile, [
|
||||
'mode' => (string)($restoreState['mode'] ?? ''),
|
||||
'switched_at' => date('c'),
|
||||
'backup_id' => $backupId,
|
||||
'rolled_back_by' => $operator,
|
||||
'rolled_back_from' => (string)($currentState['mode'] ?? ''),
|
||||
]);
|
||||
|
||||
return [
|
||||
'backup_id' => $backupId,
|
||||
'restored_files' => array_map(static function ($item) {
|
||||
return (string)($item['file'] ?? '');
|
||||
}, $files),
|
||||
];
|
||||
}
|
||||
|
||||
protected function loadConfig(): array
|
||||
{
|
||||
if (!is_file($this->stackConfigPath)) {
|
||||
throw new RuntimeException('缺少全局清单:source/stack/stack.json');
|
||||
}
|
||||
|
||||
$config = $this->readJsonFile($this->stackConfigPath);
|
||||
|
||||
$defaultMode = (string)($config['default_mode'] ?? '');
|
||||
if ($defaultMode === '') {
|
||||
throw new RuntimeException('stack.json 缺少 default_mode');
|
||||
}
|
||||
|
||||
$managedFilesRaw = $config['managed_files'] ?? [];
|
||||
if (!is_array($managedFilesRaw) || empty($managedFilesRaw)) {
|
||||
throw new RuntimeException('stack.json 缺少 managed_files');
|
||||
}
|
||||
|
||||
$managedFiles = [];
|
||||
foreach ($managedFilesRaw as $item) {
|
||||
$managedFiles[] = $this->normalizeRelativePath((string)$item);
|
||||
}
|
||||
$managedFiles = array_values(array_unique($managedFiles));
|
||||
|
||||
$modes = $config['modes'] ?? [];
|
||||
if (!is_array($modes) || empty($modes)) {
|
||||
throw new RuntimeException('stack.json 缺少 modes');
|
||||
}
|
||||
|
||||
if (!isset($modes[$defaultMode])) {
|
||||
throw new RuntimeException('default_mode 未在 modes 中声明');
|
||||
}
|
||||
|
||||
return [
|
||||
'default_mode' => $defaultMode,
|
||||
'managed_files' => $managedFiles,
|
||||
'modes' => $modes,
|
||||
];
|
||||
}
|
||||
|
||||
protected function resolveModeFile(string $mode, string $relativePath): ?string
|
||||
{
|
||||
$filePath = $this->joinPath($this->stackRoot, $mode, $this->toSystemPath($relativePath));
|
||||
if (is_file($filePath)) {
|
||||
return $filePath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function toRootPath(string $relativePath): string
|
||||
{
|
||||
return $this->joinPath($this->rootPath, $this->toSystemPath($relativePath));
|
||||
}
|
||||
|
||||
protected function normalizeRelativePath(string $path): string
|
||||
{
|
||||
$path = str_replace('\\', '/', trim($path));
|
||||
$path = preg_replace('#/+#', '/', $path);
|
||||
$path = trim((string)$path, '/');
|
||||
if ($path === '') {
|
||||
throw new RuntimeException('检测到空路径');
|
||||
}
|
||||
if (strpos($path, '..') !== false) {
|
||||
throw new RuntimeException("不允许越级路径:{$path}");
|
||||
}
|
||||
if (preg_match('/^[A-Za-z]:/', $path)) {
|
||||
throw new RuntimeException("不允许绝对路径:{$path}");
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
protected function toSystemPath(string $path): string
|
||||
{
|
||||
return str_replace('/', DIRECTORY_SEPARATOR, $path);
|
||||
}
|
||||
|
||||
protected function joinPath(string ...$parts): string
|
||||
{
|
||||
$result = '';
|
||||
foreach ($parts as $part) {
|
||||
$part = trim($part);
|
||||
if ($part === '') {
|
||||
continue;
|
||||
}
|
||||
if ($result === '') {
|
||||
$result = rtrim($part, "\\/");
|
||||
continue;
|
||||
}
|
||||
$result .= DIRECTORY_SEPARATOR . trim($part, "\\/");
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function ensureDir(string $dir): void
|
||||
{
|
||||
if ($dir === '' || is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
if (!@mkdir($dir, 0777, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException("创建目录失败:{$dir}");
|
||||
}
|
||||
}
|
||||
|
||||
protected function readJsonFile(string $file): array
|
||||
{
|
||||
$raw = file_get_contents($file);
|
||||
if ($raw === false) {
|
||||
throw new RuntimeException("读取文件失败:{$file}");
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
throw new RuntimeException("JSON 解析失败:{$file}");
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function writeJsonFile(string $file, array $data): void
|
||||
{
|
||||
$this->ensureDir(dirname($file));
|
||||
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException("JSON 编码失败:{$file}");
|
||||
}
|
||||
file_put_contents($file, $json . PHP_EOL);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ use app\common\command\admin\role\AdminRoleList;
|
||||
use app\common\command\admin\role\AdminRolePermissionAssign;
|
||||
use app\common\command\admin\role\AdminRolePermissionList;
|
||||
use app\common\command\admin\role\AdminRolePermissionRevoke;
|
||||
use app\common\command\admin\stack\AdminStackMode;
|
||||
use app\common\command\admin\user\AdminUserRoleAssign;
|
||||
use app\common\command\admin\user\AdminUserRoleList;
|
||||
use app\common\command\admin\user\AdminUserRoleRevoke;
|
||||
@@ -114,6 +115,7 @@ class UlthonAdminService extends Service
|
||||
AdminRolePermissionAssign::class,
|
||||
AdminRolePermissionRevoke::class,
|
||||
AdminRolePermissionList::class,
|
||||
AdminStackMode::class,
|
||||
AdminUserRoleAssign::class,
|
||||
AdminUserRoleRevoke::class,
|
||||
AdminUserRoleList::class,
|
||||
|
||||
28
source/stack/README.md
Normal file
28
source/stack/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Stack 模式目录规范
|
||||
|
||||
本目录用于维护“模式化生效文件”,由 `php think admin:stack:mode` 命令读取并覆盖到仓库根目录。
|
||||
|
||||
## 目录结构
|
||||
|
||||
- `source/stack/stack.json`:全局清单,定义 `default_mode`、`managed_files`、`modes` 元数据。
|
||||
- `source/stack/default/`:默认行为基线目录。
|
||||
- `source/stack/{mode}/`:具体模式目录,按“仓库相对路径”放置文件。
|
||||
|
||||
## default 目录规则(强约束)
|
||||
|
||||
- `source/stack/default/` 必须与代码库默认行为一致。
|
||||
- 当默认行为文件变更时(如 `Dockerfile`、`docker-compose.yaml`、`.gitea/workflows/build-and-deploy.yml`),必须同步更新 `default` 目录对应文件。
|
||||
- 该规则通过目录维护规范与代码评审保障,不作为每次切换命令的运行时阻断条件。
|
||||
|
||||
## 模式覆盖规则
|
||||
|
||||
- 仅允许覆盖 `stack.json` 的 `managed_files` 中声明的文件。
|
||||
- 切换时按“目标模式优先,default 模式兜底”解析最终文件内容:
|
||||
- 目标模式提供某文件:使用目标模式文件;
|
||||
- 目标模式未提供某文件:回落使用 `default` 目录对应文件。
|
||||
- 首期固定模式:`default`、`full`、`base-build`。
|
||||
|
||||
## 基础镜像说明
|
||||
|
||||
- `base-build/docker/Dockerfile.base` 为基础镜像构建文件,默认标记为作者维护范围(`author_only=true`)。
|
||||
- 推荐标签策略:`latest` + 时间戳(如 `20260424120000`)。
|
||||
33
source/stack/base-build/Dockerfile
Normal file
33
source/stack/base-build/Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
||||
ARG BASE_IMAGE=ulthon/ulthon_admin-base:latest
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# 预先拷贝 composer 文件并安装依赖,利用 Docker 缓存
|
||||
COPY composer.json composer.lock /var/www/html/
|
||||
RUN composer install --no-dev --no-interaction --no-scripts --no-autoloader
|
||||
|
||||
# 将当前目录下的文件拷贝到工作目录
|
||||
COPY . /var/www/html
|
||||
|
||||
# 生成自动加载文件
|
||||
RUN composer dump-autoload --optimize --no-dev --classmap-authoritative
|
||||
|
||||
VOLUME /var/www/html/runtime
|
||||
VOLUME /var/www/html/public/storage
|
||||
VOLUME /var/www/html/public/build
|
||||
VOLUME /var/www/html/storage
|
||||
|
||||
# 挂载主目录,也可以选择直接挂载主目录,可以把上面的几个指定的目录删掉
|
||||
# VOLUME ["/var/www/html"]
|
||||
|
||||
# 暴露 Nginx 端口
|
||||
EXPOSE 80
|
||||
|
||||
RUN chmod +x /var/www/html/docker/run.sh
|
||||
|
||||
# 启动 Nginx PHP 然后阻塞
|
||||
ENTRYPOINT ["/var/www/html/docker/run.sh"]
|
||||
|
||||
CMD ["server"]
|
||||
20
source/stack/base-build/docker-compose.yaml
Normal file
20
source/stack/base-build/docker-compose.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
|
||||
name: ulthon_admin
|
||||
|
||||
|
||||
services:
|
||||
ulthon_admin:
|
||||
# 正式环境中,您应当构建一个完整镜像,使用镜像名称或id运行,不要使用dockerfile
|
||||
# image: ulthon/ulthon_admin:v1
|
||||
build:
|
||||
context: . # Dockerfile 所在的目录
|
||||
dockerfile: Dockerfile # Dockerfile 的名称
|
||||
restart: always
|
||||
ports:
|
||||
- "88:80" # HTTP
|
||||
volumes:
|
||||
- ./:/var/www/html # 直接分发代码可以去掉注释并将下面的目录增加注释
|
||||
# - ./runtime:/var/www/html/runtime
|
||||
# - ./public/storage:/var/www/html/public/storage
|
||||
# - ./public/build:/var/www/html/public/build
|
||||
# - ./storage:/var/www/html/storage
|
||||
39
source/stack/base-build/docker/Dockerfile.base
Normal file
39
source/stack/base-build/docker/Dockerfile.base
Normal file
@@ -0,0 +1,39 @@
|
||||
FROM php:8.2-fpm-bookworm
|
||||
|
||||
RUN rm -rf /etc/apt/sources.list.d/* \
|
||||
&& echo "deb http://mirrors.ustc.edu.cn/debian/ bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list \
|
||||
&& echo "deb http://mirrors.ustc.edu.cn/debian/ bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list \
|
||||
&& echo "deb http://mirrors.ustc.edu.cn/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list
|
||||
|
||||
RUN apt-get update
|
||||
|
||||
# 安装 nginx
|
||||
RUN apt-get install -y nginx
|
||||
|
||||
ADD --chmod=0755 https://nexus.hl7.top:1243/repository/github-raw-proxy/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
|
||||
|
||||
# 配置代理
|
||||
RUN sed -i 's|aomedia.googlesource.com|nexus.hl7.top:1243/repository/raw-aomedia.googlesource.com|g' /usr/local/bin/install-php-extensions
|
||||
RUN sed -i 's|chromium.googlesource.com|nexus.hl7.top:1243/repository/raw-chromium.googlesource.com|g' /usr/local/bin/install-php-extensions
|
||||
RUN sed -i 's|https://github.com|https://nexus.hl7.top:1243/repository/github-raw-proxy|g' /usr/local/bin/install-php-extensions
|
||||
|
||||
RUN install-php-extensions pdo_mysql
|
||||
RUN install-php-extensions gd
|
||||
RUN install-php-extensions fileinfo
|
||||
RUN install-php-extensions opcache
|
||||
RUN install-php-extensions redis
|
||||
RUN install-php-extensions event
|
||||
RUN install-php-extensions imagick
|
||||
RUN install-php-extensions zip
|
||||
RUN install-php-extensions pcntl
|
||||
|
||||
# 清理默认 Nginx 配置
|
||||
RUN rm /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default
|
||||
|
||||
# 安装 Composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
RUN chmod +x /usr/local/bin/composer
|
||||
RUN composer config -g repos.packagist composer https://nexus.hl7.top:1243/repository/composer-proxy/
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /var/www/html
|
||||
140
source/stack/default/.gitea/workflows/build-and-deploy.yml
Normal file
140
source/stack/default/.gitea/workflows/build-and-deploy.yml
Normal file
@@ -0,0 +1,140 @@
|
||||
name: build-and-deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REMOTE_APP_DIR: /data/projects/ulthon_admin/docker
|
||||
PACKAGE_NAME: ulthon_admin_release.tar.gz
|
||||
COMPOSE_PROJECT_NAME: ulthon_admin
|
||||
DB_HOSTNAME: host.docker.internal
|
||||
|
||||
jobs:
|
||||
deploy_host15:
|
||||
name: 直传代码并部署到 Host15
|
||||
runs-on: main
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: 生成 .env
|
||||
shell: bash
|
||||
env:
|
||||
MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
|
||||
DB_HOSTNAME: ${{ env.DB_HOSTNAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp .example.env .env
|
||||
|
||||
awk -v host="$DB_HOSTNAME" -v newpwd="$MYSQL_PASSWORD" '
|
||||
BEGIN { has_host = 0; has_pwd = 0 }
|
||||
$0 ~ /^HOSTNAME=/ {
|
||||
print "HOSTNAME=" host
|
||||
has_host = 1
|
||||
next
|
||||
}
|
||||
$0 ~ /^PASSWORD=/ {
|
||||
print "PASSWORD=" newpwd
|
||||
has_pwd = 1
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
END {
|
||||
if (!has_host) print "HOSTNAME=" host
|
||||
if (!has_pwd) print "PASSWORD=" newpwd
|
||||
}' .env > .env.tmp
|
||||
|
||||
mv .env.tmp .env
|
||||
|
||||
- name: 打包发布文件
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_NAME: ${{ env.PACKAGE_NAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TMP_PACKAGE="/tmp/${PACKAGE_NAME}"
|
||||
rm -f "$TMP_PACKAGE" "$PACKAGE_NAME"
|
||||
|
||||
tar -czf "$TMP_PACKAGE" \
|
||||
--exclude="$PACKAGE_NAME" \
|
||||
--exclude-vcs \
|
||||
--exclude="./runtime/*" \
|
||||
--exclude="./.trae/*" \
|
||||
--exclude="./source/clients/uniapp/node_modules/*" \
|
||||
.
|
||||
cp -f "$TMP_PACKAGE" "$PACKAGE_NAME"
|
||||
|
||||
- name: 创建远端目录
|
||||
uses: appleboy/ssh-action@v0.1.10
|
||||
env:
|
||||
REMOTE_APP_DIR: ${{ env.REMOTE_APP_DIR }}
|
||||
with:
|
||||
host: ${{ secrets.UL_HOST15_IP }}
|
||||
username: ${{ secrets.UL_HOST15_USER }}
|
||||
password: ${{ secrets.UL_HOST15_PASSWORD }}
|
||||
port: ${{ secrets.UL_HOST15_PORT }}
|
||||
envs: REMOTE_APP_DIR
|
||||
script: |
|
||||
set -euo pipefail
|
||||
mkdir -p "${REMOTE_APP_DIR}/incoming"
|
||||
mkdir -p "${REMOTE_APP_DIR}/releases"
|
||||
|
||||
- name: 上传发布包与 compose
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
env:
|
||||
REMOTE_APP_DIR: ${{ env.REMOTE_APP_DIR }}
|
||||
PACKAGE_NAME: ${{ env.PACKAGE_NAME }}
|
||||
with:
|
||||
host: ${{ secrets.UL_HOST15_IP }}
|
||||
username: ${{ secrets.UL_HOST15_USER }}
|
||||
password: ${{ secrets.UL_HOST15_PASSWORD }}
|
||||
port: ${{ secrets.UL_HOST15_PORT }}
|
||||
source: "${{ env.PACKAGE_NAME }},docker-compose.yaml"
|
||||
target: "${{ env.REMOTE_APP_DIR }}/incoming"
|
||||
overwrite: true
|
||||
|
||||
- name: 远端解压并启动
|
||||
uses: appleboy/ssh-action@v0.1.10
|
||||
env:
|
||||
REMOTE_APP_DIR: ${{ env.REMOTE_APP_DIR }}
|
||||
PACKAGE_NAME: ${{ env.PACKAGE_NAME }}
|
||||
COMPOSE_PROJECT_NAME: ${{ env.COMPOSE_PROJECT_NAME }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
with:
|
||||
host: ${{ secrets.UL_HOST15_IP }}
|
||||
username: ${{ secrets.UL_HOST15_USER }}
|
||||
password: ${{ secrets.UL_HOST15_PASSWORD }}
|
||||
port: ${{ secrets.UL_HOST15_PORT }}
|
||||
timeout: 120s
|
||||
command_timeout: 60m
|
||||
envs: REMOTE_APP_DIR,PACKAGE_NAME,COMPOSE_PROJECT_NAME,GITHUB_RUN_ID
|
||||
script: |
|
||||
set -euo pipefail
|
||||
|
||||
RELEASE_DIR="${REMOTE_APP_DIR}/releases/${GITHUB_RUN_ID}"
|
||||
PACKAGE_PATH="${REMOTE_APP_DIR}/incoming/${PACKAGE_NAME}"
|
||||
COMPOSE_PATH="${REMOTE_APP_DIR}/incoming/docker-compose.yaml"
|
||||
|
||||
rm -rf "${RELEASE_DIR}"
|
||||
mkdir -p "${RELEASE_DIR}"
|
||||
|
||||
tar -xzf "${PACKAGE_PATH}" -C "${RELEASE_DIR}"
|
||||
|
||||
if [ -f "${COMPOSE_PATH}" ]; then
|
||||
cp -f "${COMPOSE_PATH}" "${RELEASE_DIR}/docker-compose.yaml"
|
||||
fi
|
||||
|
||||
cd "${RELEASE_DIR}"
|
||||
export COMPOSE_PROJECT_NAME
|
||||
|
||||
docker compose down || true
|
||||
docker compose up -d --build --remove-orphans
|
||||
|
||||
ln -sfn "${RELEASE_DIR}" "${REMOTE_APP_DIR}/current"
|
||||
|
||||
ls -1dt "${REMOTE_APP_DIR}/releases"/* 2>/dev/null | tail -n +6 | xargs -r rm -rf
|
||||
81
source/stack/default/Dockerfile
Normal file
81
source/stack/default/Dockerfile
Normal file
@@ -0,0 +1,81 @@
|
||||
FROM php:8.2-fpm-bookworm
|
||||
|
||||
RUN rm -rf /etc/apt/sources.list.d/* \
|
||||
&& echo "deb http://mirrors.ustc.edu.cn/debian/ bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list \
|
||||
&& echo "deb http://mirrors.ustc.edu.cn/debian/ bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list \
|
||||
&& echo "deb http://mirrors.ustc.edu.cn/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list
|
||||
|
||||
RUN apt-get update
|
||||
|
||||
# 安装nginx
|
||||
RUN apt-get install -y nginx
|
||||
|
||||
ADD --chmod=0755 https://nexus.hl7.top:1243/repository/github-raw-proxy/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
|
||||
|
||||
# 配置代理
|
||||
RUN sed -i 's|aomedia.googlesource.com|nexus.hl7.top:1243/repository/raw-aomedia.googlesource.com|g' /usr/local/bin/install-php-extensions
|
||||
RUN sed -i 's|chromium.googlesource.com|nexus.hl7.top:1243/repository/raw-chromium.googlesource.com|g' /usr/local/bin/install-php-extensions
|
||||
RUN sed -i 's|https://github.com|https://nexus.hl7.top:1243/repository/github-raw-proxy|g' /usr/local/bin/install-php-extensions
|
||||
|
||||
RUN install-php-extensions pdo_mysql
|
||||
RUN install-php-extensions gd
|
||||
RUN install-php-extensions fileinfo
|
||||
RUN install-php-extensions opcache
|
||||
RUN install-php-extensions redis
|
||||
RUN install-php-extensions event
|
||||
RUN install-php-extensions imagick
|
||||
RUN install-php-extensions zip
|
||||
RUN install-php-extensions pcntl
|
||||
|
||||
# 清理默认 Nginx 配置
|
||||
RUN rm /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default
|
||||
|
||||
|
||||
# 安装其他需要的依赖
|
||||
# RUN apt-get install -y ffmpeg
|
||||
# RUN apt-get install -y libreoffice
|
||||
# RUN apt-get install -y redis-server
|
||||
# RUN apt-get install -y git
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# 安装 Composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
RUN chmod +x /usr/local/bin/composer
|
||||
RUN composer config -g repos.packagist composer https://nexus.hl7.top:1243/repository/composer-proxy/
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# 预先拷贝 composer 文件并安装依赖,利用 Docker 缓存
|
||||
COPY composer.json composer.lock /var/www/html/
|
||||
RUN composer install --no-dev --no-interaction --no-scripts --no-autoloader
|
||||
|
||||
# 将当前目录下的文件拷贝到工作目录
|
||||
COPY . /var/www/html
|
||||
|
||||
# 生成自动加载文件
|
||||
RUN composer dump-autoload --optimize --no-dev --classmap-authoritative
|
||||
|
||||
# 内部安装compsoer并安装依赖,如果不需要可以注释掉
|
||||
# RUN install-php-extensions @composer
|
||||
|
||||
VOLUME /var/www/html/runtime
|
||||
VOLUME /var/www/html/public/storage
|
||||
VOLUME /var/www/html/public/build
|
||||
VOLUME /var/www/html/storage
|
||||
|
||||
# 挂载主目录,也可以选择直接挂载主目录,可以把上面的几个指定的目录删掉
|
||||
# VOLUME ["/var/www/html"]
|
||||
|
||||
# 暴露 Nginx 端口
|
||||
EXPOSE 80
|
||||
|
||||
RUN chmod +x /var/www/html/docker/run.sh
|
||||
|
||||
# 启动 Nginx PHP 然后阻塞
|
||||
ENTRYPOINT ["/var/www/html/docker/run.sh"]
|
||||
|
||||
CMD ["server"]
|
||||
|
||||
20
source/stack/default/docker-compose.yaml
Normal file
20
source/stack/default/docker-compose.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
|
||||
name: ulthon_admin
|
||||
|
||||
|
||||
services:
|
||||
ulthon_admin:
|
||||
# 正式环境中,您应当构建一个完整镜像,使用镜像名称或id运行,不要使用dockerfile
|
||||
# image: ulthon/ulthon_admin:v1
|
||||
build:
|
||||
context: . # Dockerfile 所在的目录
|
||||
dockerfile: Dockerfile # Dockerfile 的名称
|
||||
restart: always
|
||||
ports:
|
||||
- "88:80" # HTTP
|
||||
volumes:
|
||||
- ./:/var/www/html # 直接分发代码可以去掉注释并将下面的目录增加注释
|
||||
# - ./runtime:/var/www/html/runtime
|
||||
# - ./public/storage:/var/www/html/public/storage
|
||||
# - ./public/build:/var/www/html/public/build
|
||||
# - ./storage:/var/www/html/storage
|
||||
81
source/stack/full/Dockerfile
Normal file
81
source/stack/full/Dockerfile
Normal file
@@ -0,0 +1,81 @@
|
||||
FROM php:8.2-fpm-bookworm
|
||||
|
||||
RUN rm -rf /etc/apt/sources.list.d/* \
|
||||
&& echo "deb http://mirrors.ustc.edu.cn/debian/ bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list \
|
||||
&& echo "deb http://mirrors.ustc.edu.cn/debian/ bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list \
|
||||
&& echo "deb http://mirrors.ustc.edu.cn/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list
|
||||
|
||||
RUN apt-get update
|
||||
|
||||
# 安装nginx
|
||||
RUN apt-get install -y nginx
|
||||
|
||||
ADD --chmod=0755 https://nexus.hl7.top:1243/repository/github-raw-proxy/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
|
||||
|
||||
# 配置代理
|
||||
RUN sed -i 's|aomedia.googlesource.com|nexus.hl7.top:1243/repository/raw-aomedia.googlesource.com|g' /usr/local/bin/install-php-extensions
|
||||
RUN sed -i 's|chromium.googlesource.com|nexus.hl7.top:1243/repository/raw-chromium.googlesource.com|g' /usr/local/bin/install-php-extensions
|
||||
RUN sed -i 's|https://github.com|https://nexus.hl7.top:1243/repository/github-raw-proxy|g' /usr/local/bin/install-php-extensions
|
||||
|
||||
RUN install-php-extensions pdo_mysql
|
||||
RUN install-php-extensions gd
|
||||
RUN install-php-extensions fileinfo
|
||||
RUN install-php-extensions opcache
|
||||
RUN install-php-extensions redis
|
||||
RUN install-php-extensions event
|
||||
RUN install-php-extensions imagick
|
||||
RUN install-php-extensions zip
|
||||
RUN install-php-extensions pcntl
|
||||
|
||||
# 清理默认 Nginx 配置
|
||||
RUN rm /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default
|
||||
|
||||
|
||||
# 安装其他需要的依赖
|
||||
# RUN apt-get install -y ffmpeg
|
||||
# RUN apt-get install -y libreoffice
|
||||
# RUN apt-get install -y redis-server
|
||||
# RUN apt-get install -y git
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# 安装 Composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
RUN chmod +x /usr/local/bin/composer
|
||||
RUN composer config -g repos.packagist composer https://nexus.hl7.top:1243/repository/composer-proxy/
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# 预先拷贝 composer 文件并安装依赖,利用 Docker 缓存
|
||||
COPY composer.json composer.lock /var/www/html/
|
||||
RUN composer install --no-dev --no-interaction --no-scripts --no-autoloader
|
||||
|
||||
# 将当前目录下的文件拷贝到工作目录
|
||||
COPY . /var/www/html
|
||||
|
||||
# 生成自动加载文件
|
||||
RUN composer dump-autoload --optimize --no-dev --classmap-authoritative
|
||||
|
||||
# 内部安装compsoer并安装依赖,如果不需要可以注释掉
|
||||
# RUN install-php-extensions @composer
|
||||
|
||||
VOLUME /var/www/html/runtime
|
||||
VOLUME /var/www/html/public/storage
|
||||
VOLUME /var/www/html/public/build
|
||||
VOLUME /var/www/html/storage
|
||||
|
||||
# 挂载主目录,也可以选择直接挂载主目录,可以把上面的几个指定的目录删掉
|
||||
# VOLUME ["/var/www/html"]
|
||||
|
||||
# 暴露 Nginx 端口
|
||||
EXPOSE 80
|
||||
|
||||
RUN chmod +x /var/www/html/docker/run.sh
|
||||
|
||||
# 启动 Nginx PHP 然后阻塞
|
||||
ENTRYPOINT ["/var/www/html/docker/run.sh"]
|
||||
|
||||
CMD ["server"]
|
||||
|
||||
20
source/stack/full/docker-compose.yaml
Normal file
20
source/stack/full/docker-compose.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
|
||||
name: ulthon_admin
|
||||
|
||||
|
||||
services:
|
||||
ulthon_admin:
|
||||
# 正式环境中,您应当构建一个完整镜像,使用镜像名称或id运行,不要使用dockerfile
|
||||
# image: ulthon/ulthon_admin:v1
|
||||
build:
|
||||
context: . # Dockerfile 所在的目录
|
||||
dockerfile: Dockerfile # Dockerfile 的名称
|
||||
restart: always
|
||||
ports:
|
||||
- "88:80" # HTTP
|
||||
volumes:
|
||||
- ./:/var/www/html # 直接分发代码可以去掉注释并将下面的目录增加注释
|
||||
# - ./runtime:/var/www/html/runtime
|
||||
# - ./public/storage:/var/www/html/public/storage
|
||||
# - ./public/build:/var/www/html/public/build
|
||||
# - ./storage:/var/www/html/storage
|
||||
22
source/stack/stack.json
Normal file
22
source/stack/stack.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"default_mode": "default",
|
||||
"managed_files": [
|
||||
"Dockerfile",
|
||||
"docker-compose.yaml",
|
||||
".gitea/workflows/build-and-deploy.yml"
|
||||
],
|
||||
"modes": {
|
||||
"default": {
|
||||
"description": "代码库默认行为基线",
|
||||
"author_only": false
|
||||
},
|
||||
"full": {
|
||||
"description": "全量构建模式(兼容历史行为)",
|
||||
"author_only": false
|
||||
},
|
||||
"base-build": {
|
||||
"description": "基础镜像 + 应用构建模式",
|
||||
"author_only": true
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user