From b44fcfd86cb5bd31f2e78c444e4264a80ef7cc74 Mon Sep 17 00:00:00 2001 From: augushong Date: Fri, 24 Apr 2026 23:20:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(stack):=20=E6=96=B0=E5=A2=9E=20stack=20?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `php think admin:stack:mode` 命令,支持 list/use/current/rollback 操作 - 新增 StackModeService 服务,负责模式切换、备份与回滚逻辑 - 在 source/stack/ 目录下添加 default、full、base-build 三种模式的配置文件 - 更新 UlthonAdminService 以注册新的命令行工具 --- .../command/admin/stack/AdminStackMode.php | 9 + app/common/service/stack/StackModeService.php | 9 + .../admin/stack/AdminStackModeBase.php | 135 +++++++ .../service/stack/StackModeServiceBase.php | 371 ++++++++++++++++++ extend/think/UlthonAdminService.php | 2 + source/stack/README.md | 28 ++ source/stack/base-build/Dockerfile | 33 ++ source/stack/base-build/docker-compose.yaml | 20 + .../stack/base-build/docker/Dockerfile.base | 39 ++ .../.gitea/workflows/build-and-deploy.yml | 140 +++++++ source/stack/default/Dockerfile | 81 ++++ source/stack/default/docker-compose.yaml | 20 + source/stack/full/Dockerfile | 81 ++++ source/stack/full/docker-compose.yaml | 20 + source/stack/stack.json | 22 ++ 15 files changed, 1010 insertions(+) create mode 100644 app/common/command/admin/stack/AdminStackMode.php create mode 100644 app/common/service/stack/StackModeService.php create mode 100644 extend/base/common/command/admin/stack/AdminStackModeBase.php create mode 100644 extend/base/common/service/stack/StackModeServiceBase.php create mode 100644 source/stack/README.md create mode 100644 source/stack/base-build/Dockerfile create mode 100644 source/stack/base-build/docker-compose.yaml create mode 100644 source/stack/base-build/docker/Dockerfile.base create mode 100644 source/stack/default/.gitea/workflows/build-and-deploy.yml create mode 100644 source/stack/default/Dockerfile create mode 100644 source/stack/default/docker-compose.yaml create mode 100644 source/stack/full/Dockerfile create mode 100644 source/stack/full/docker-compose.yaml create mode 100644 source/stack/stack.json diff --git a/app/common/command/admin/stack/AdminStackMode.php b/app/common/command/admin/stack/AdminStackMode.php new file mode 100644 index 0000000..0a5e361 --- /dev/null +++ b/app/common/command/admin/stack/AdminStackMode.php @@ -0,0 +1,9 @@ +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'])); + } +} diff --git a/extend/base/common/service/stack/StackModeServiceBase.php b/extend/base/common/service/stack/StackModeServiceBase.php new file mode 100644 index 0000000..57a5701 --- /dev/null +++ b/extend/base/common/service/stack/StackModeServiceBase.php @@ -0,0 +1,371 @@ +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); + } +} diff --git a/extend/think/UlthonAdminService.php b/extend/think/UlthonAdminService.php index 793fdaf..0326dd3 100644 --- a/extend/think/UlthonAdminService.php +++ b/extend/think/UlthonAdminService.php @@ -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, diff --git a/source/stack/README.md b/source/stack/README.md new file mode 100644 index 0000000..bba6183 --- /dev/null +++ b/source/stack/README.md @@ -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`)。 diff --git a/source/stack/base-build/Dockerfile b/source/stack/base-build/Dockerfile new file mode 100644 index 0000000..ee70675 --- /dev/null +++ b/source/stack/base-build/Dockerfile @@ -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"] diff --git a/source/stack/base-build/docker-compose.yaml b/source/stack/base-build/docker-compose.yaml new file mode 100644 index 0000000..8de07f5 --- /dev/null +++ b/source/stack/base-build/docker-compose.yaml @@ -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 diff --git a/source/stack/base-build/docker/Dockerfile.base b/source/stack/base-build/docker/Dockerfile.base new file mode 100644 index 0000000..5182cf4 --- /dev/null +++ b/source/stack/base-build/docker/Dockerfile.base @@ -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 diff --git a/source/stack/default/.gitea/workflows/build-and-deploy.yml b/source/stack/default/.gitea/workflows/build-and-deploy.yml new file mode 100644 index 0000000..cb709c7 --- /dev/null +++ b/source/stack/default/.gitea/workflows/build-and-deploy.yml @@ -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 diff --git a/source/stack/default/Dockerfile b/source/stack/default/Dockerfile new file mode 100644 index 0000000..40e1195 --- /dev/null +++ b/source/stack/default/Dockerfile @@ -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"] + diff --git a/source/stack/default/docker-compose.yaml b/source/stack/default/docker-compose.yaml new file mode 100644 index 0000000..8de07f5 --- /dev/null +++ b/source/stack/default/docker-compose.yaml @@ -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 diff --git a/source/stack/full/Dockerfile b/source/stack/full/Dockerfile new file mode 100644 index 0000000..40e1195 --- /dev/null +++ b/source/stack/full/Dockerfile @@ -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"] + diff --git a/source/stack/full/docker-compose.yaml b/source/stack/full/docker-compose.yaml new file mode 100644 index 0000000..8de07f5 --- /dev/null +++ b/source/stack/full/docker-compose.yaml @@ -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 diff --git a/source/stack/stack.json b/source/stack/stack.json new file mode 100644 index 0000000..f887bcb --- /dev/null +++ b/source/stack/stack.json @@ -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 + } + } +}