diff --git a/ops/README.md b/ops/README.md new file mode 100644 index 0000000..4378fc0 --- /dev/null +++ b/ops/README.md @@ -0,0 +1,32 @@ +# 推送自动部署 + +服务器每 60 秒检查两个仓库的 `master`,发现新提交后自动发布。其他分支不发布。 + +- 后端:`wikiboss/chengduzhongchuliang` → `/www/wwwroot/grain.gynhc.com` +- 大屏:`wikiboss/zhongculiang` 的 HTML、css、js、assets 源文件 → 后端 `public/screen` + +前端直接发布静态源文件,不依赖提交者是否更新 dist。后端发布 application、public、extend、addons;public/screen 由前端仓库独立管理。数据库配置、站点配置、上传文件、环境变量、runtime、vendor 和框架不覆盖。SQL 文件不会自动导入,数据库结构变更需要单独迁移。 + +脚本使用独立 Git 缓存,不重置网站目录内原有 Git 工作区。变更先备份,PHP 语法检查通过后逐文件替换,接口和页面检查失败会恢复本轮文件,下一轮会重试。前后端分别记录部署结果。 + +服务器文件: + +- 程序:`/opt/grain-auto-deploy/auto_deploy.py` +- 配置:`/etc/grain-auto-deploy.json` +- 版本及文件清单:`/var/lib/grain-auto-deploy/backend.json`、`frontend.json` +- 备份:`/www/backup/grain-auto-deploy/` +- 日志:`/var/log/grain-auto-deploy.log`,以及 `journalctl -u grain-auto-deploy.service` + +运维命令: + +```sh +systemctl list-timers grain-auto-deploy.timer +systemctl start grain-auto-deploy.service +systemctl stop grain-auto-deploy.timer # 暂停后续自动发布 +systemctl start grain-auto-deploy.timer # 恢复 +tail -n 60 /var/log/grain-auto-deploy.log +``` + +首次安装需将程序、service、timer 文件安装到上述位置,写入部署配置并运行 `systemctl daemon-reload` 和 `systemctl enable --now grain-auto-deploy.timer`。程序以网站用户 www 运行,仅开放站点、状态、备份和日志的写权限,Git 工作区及线上配置额外设为只读。配置文件由 root:www 持有,权限 0640;程序及 systemd 单元仅 root 可修改。普通业务推送不会替换正在执行的部署程序。 + +验证:`python3 -m unittest discover -s ops -p test_auto_deploy.py`。 diff --git a/ops/auto_deploy.py b/ops/auto_deploy.py new file mode 100644 index 0000000..db6260b --- /dev/null +++ b/ops/auto_deploy.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Publish master from dedicated Git caches; retain production data/configuration.""" +import argparse +import datetime +import hashlib +import io +import json +import logging +import os +from pathlib import Path, PurePosixPath +import shutil +import subprocess +import tarfile +import tempfile +import urllib.request + +LOG = logging.getLogger('grain-deploy') + + +def run(args): + result = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180) + if result.returncode: + raise RuntimeError('Command failed: {}: {}'.format(args[0], result.stderr.decode(errors='replace')[-1500:])) + return result.stdout + + +def selected(name, kind): + parts = PurePosixPath(name).parts + if not parts or any(p in ('.', '..') or p.startswith('.') for p in parts): + return False + if kind == 'frontend': + return name in ('index.html', 'xishui.html') or parts[0] in ('css', 'js', 'assets') + if name in ('application/database.php', 'application/config.php', 'application/extra/site.php'): + return False + if parts[0] == 'public' and len(parts) > 1 and parts[1] in ('uploads', 'screen', 'install'): + return False + return parts[0] in ('application', 'public', 'extend', 'addons') + + +def atomic_json(path, data): + path.parent.mkdir(parents=True, exist_ok=True) + temp = path.with_suffix('.tmp') + temp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8') + os.chmod(str(temp), 0o600) + os.replace(str(temp), str(path)) + + +def destination(root, name): + result = root / name + if os.path.commonpath([str(root.resolve()), str(result.resolve())]) != str(root.resolve()): + raise RuntimeError('Deployment path escapes site root: ' + name) + return result + + +def healthcheck(url, host): + req = urllib.request.Request(url, headers={'Host': host, 'Cache-Control': 'no-cache'}) + with urllib.request.urlopen(req, timeout=20) as response: + body = response.read() + if response.status != 200: + raise RuntimeError('Health check failed: ' + url) + if '/granaryList' in url: + data = json.loads(body.decode('utf-8')) + if data.get('code') != 1 or not isinstance(data.get('data', {}).get('list'), list): + raise RuntimeError('Granary API health check failed') + + +def publish(config, repo): + state_dir = Path(config['state']) + cache = state_dir / (repo['name'] + '.git') + cache.parent.mkdir(parents=True, exist_ok=True) + if not cache.exists(): + run(['git', 'init', '--bare', str(cache)]) + git = ['git', '--git-dir=' + str(cache)] + run(git + ['fetch', '--depth=1', repo['url'], '+refs/heads/master:refs/heads/deploy']) + revision = run(git + ['rev-parse', 'refs/heads/deploy']).decode().strip() + state_path = state_dir / (repo['name'] + '.json') + state = json.loads(state_path.read_text()) if state_path.exists() else {} + if state.get('revision') == revision: + LOG.info('%s unchanged %s', repo['name'], revision[:12]) + return False + root = Path(repo['target']) + root.mkdir(parents=True, exist_ok=True) + timestamp = datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%S%fZ') + backup = Path(config['backups']) / (timestamp + '-' + repo['name'] + '-' + revision[:12]) + with tempfile.TemporaryDirectory(prefix='grain-deploy-', dir=str(state_dir)) as directory: + stage = Path(directory) + manifest = {} + top_level = run(git + ['ls-tree', '--name-only', revision]).decode().splitlines() + prefixes = [name for name in top_level if selected(name, repo['name']) or selected(name + '/file', repo['name'])] + if not prefixes: + raise RuntimeError('No deployable source directories') + archive = run(git + ['archive', revision] + prefixes) + with tarfile.open(fileobj=io.BytesIO(archive)) as tar: + for entry in tar: + if not selected(entry.name, repo['name']): + continue + if entry.isdir(): + continue + if not entry.isfile(): + raise RuntimeError('Only regular deployment files are supported: ' + entry.name) + data = tar.extractfile(entry).read() + path = stage / entry.name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + manifest[entry.name] = hashlib.sha256(data).hexdigest() + if not manifest: + raise RuntimeError('Refusing empty deployment') + changes = [] + for name, digest in manifest.items(): + live = destination(root, name) + if not live.is_file() or hashlib.sha256(live.read_bytes()).hexdigest() != digest: + changes.append(name) + deleted = sorted(set(state.get('files', [])) - set(manifest)) + deleted = [name for name in deleted if selected(name, repo['name'])] + for name in changes: + path = stage / name + if path.suffix == '.php': + run([config['php'], '-l', str(path)]) + elif path.suffix == '.js' and shutil.which('node'): + # Browser ES modules are validated by the frontend test/build workflow. + if not name.startswith('js/'): + run(['node', '--check', str(path)]) + backup.mkdir(parents=True, mode=0o700) + records = [] + for name in sorted(set(changes + deleted)): + live = destination(root, name) + record = {'path': name, 'existed': live.is_file()} + if live.exists() and not live.is_file(): + raise RuntimeError('Expected a regular file: ' + name) + if live.is_file(): + saved = backup / 'files' / name + saved.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(live), str(saved)) + records.append(record) + atomic_json(backup / 'manifest.json', {'root': str(root), 'files': records, 'revision': revision}) + try: + for name in changes: + live = destination(root, name) + live.parent.mkdir(parents=True, exist_ok=True) + temp = live.with_name(live.name + '.deploy-tmp') + shutil.copyfile(str(stage / name), str(temp)) + if live.exists(): + stat = live.stat() + os.chmod(str(temp), stat.st_mode & 0o777) + if hasattr(os, 'chown'): + os.chown(str(temp), stat.st_uid, stat.st_gid) + else: + os.chmod(str(temp), 0o644) + if hasattr(os, 'chown'): + parent = live.parent.stat() + os.chown(str(temp), parent.st_uid, parent.st_gid) + os.replace(str(temp), str(live)) + for name in deleted: + live = destination(root, name) + if live.is_file(): + live.unlink() + for url in config.get('health', []): + healthcheck(url, config['host']) + except Exception: + for record in reversed(records): + live = destination(root, record['path']) + if record['existed']: + shutil.copy2(str(backup / 'files' / record['path']), str(live)) + elif live.is_file(): + live.unlink() + LOG.exception('%s rolled back; backup=%s', repo['name'], backup) + raise + atomic_json(state_path, {'revision': revision, 'deployed_at': timestamp, 'files': list(manifest), 'backup': str(backup)}) + LOG.info('%s deployed %s changed=%d removed=%d backup=%s', repo['name'], revision[:12], len(changes), len(deleted), backup) + return True + + +def main(): + import fcntl + parser = argparse.ArgumentParser() + parser.add_argument('--config', default='/etc/grain-auto-deploy.json') + args = parser.parse_args() + config = json.loads(Path(args.config).read_text()) + state = Path(config['state']) + state.mkdir(parents=True, exist_ok=True) + logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s', + handlers=[logging.StreamHandler(), logging.FileHandler(config['log'])]) + with (state / 'deploy.lock').open('w') as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return + failures = 0 + for repo in config['repos']: + try: + publish(config, repo) + except Exception: + failures += 1 + LOG.exception('%s deployment failed', repo['name']) + if failures: + raise SystemExit(1) + + +if __name__ == '__main__': + main() diff --git a/ops/grain-auto-deploy.service b/ops/grain-auto-deploy.service new file mode 100644 index 0000000..e0e1b0d --- /dev/null +++ b/ops/grain-auto-deploy.service @@ -0,0 +1,19 @@ +[Unit] +Description=Deploy grain backend and screen master branches +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=www +Group=www +ExecStart=/usr/bin/python3 /opt/grain-auto-deploy/auto_deploy.py +TimeoutStartSec=600 +UMask=0022 +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +ReadWritePaths=/var/lib/grain-auto-deploy /www/backup/grain-auto-deploy /www/wwwroot/grain.gynhc.com /var/log/grain-auto-deploy.log +ReadOnlyPaths=/www/wwwroot/grain.gynhc.com/.git /www/wwwroot/grain.gynhc.com/application/database.php /www/wwwroot/grain.gynhc.com/application/config.php /www/wwwroot/grain.gynhc.com/application/extra/site.php +RestrictSUIDSGID=true diff --git a/ops/grain-auto-deploy.timer b/ops/grain-auto-deploy.timer new file mode 100644 index 0000000..3a610a3 --- /dev/null +++ b/ops/grain-auto-deploy.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Check grain master branches for automatic deployment + +[Timer] +OnBootSec=45s +OnUnitInactiveSec=60s +AccuracySec=5s +Unit=grain-auto-deploy.service + +[Install] +WantedBy=timers.target diff --git a/ops/test_auto_deploy.py b/ops/test_auto_deploy.py new file mode 100644 index 0000000..ea445e4 --- /dev/null +++ b/ops/test_auto_deploy.py @@ -0,0 +1,106 @@ +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('auto_deploy', Path(__file__).with_name('auto_deploy.py')) +deploy = importlib.util.module_from_spec(spec) +spec.loader.exec_module(deploy) + + +class DeployTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.repo = self.base / 'source' + self.repo.mkdir() + self.git('init') + self.git('symbolic-ref', 'HEAD', 'refs/heads/master') + self.config = {'state': str(self.base / 'state'), 'backups': str(self.base / 'backups'), + 'host': 'localhost', 'php': 'php', 'health': []} + self.target = self.base / 'site' + self.target.mkdir() + self.settings = {'name': 'backend', 'url': str(self.repo), 'target': str(self.target)} + + def git(self, *args): + return subprocess.check_output(['git', '-C', str(self.repo)] + list(args), stderr=subprocess.STDOUT) + + def write(self, name, value): + p = self.repo / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(value) + + def commit(self): + self.git('add', '-A') + self.git('-c', 'user.name=Deploy Test', '-c', 'user.email=deploy@example.invalid', 'commit', '-m', 'fixture') + + def test_publish_and_repeat_without_republishing(self): + self.write('public/app.txt', 'first') + self.commit() + self.assertTrue(deploy.publish(self.config, self.settings)) + self.assertEqual((self.target / 'public/app.txt').read_text(), 'first') + self.assertFalse(deploy.publish(self.config, self.settings)) + + def test_preserves_production_config_uploads_and_screen(self): + for name in ['application/database.php', 'application/config.php', 'application/extra/site.php', + 'public/uploads/photo.jpg', 'public/screen/index.html']: + self.write(name, 'repository version') + p = self.target / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text('production version') + self.write('public/app.txt', 'deploy me') + self.commit() + deploy.publish(self.config, self.settings) + self.assertEqual((self.target / 'application/database.php').read_text(), 'production version') + self.assertEqual((self.target / 'public/uploads/photo.jpg').read_text(), 'production version') + self.assertEqual((self.target / 'public/screen/index.html').read_text(), 'production version') + + def test_health_failure_restores_files_and_does_not_advance_revision(self): + self.write('public/app.txt', 'first') + self.commit() + deploy.publish(self.config, self.settings) + state = (Path(self.config['state']) / 'backend.json').read_text() + self.write('public/app.txt', 'broken') + self.write('public/new.txt', 'new') + self.commit() + self.config['health'] = ['http://localhost/health'] + with patch.object(deploy, 'healthcheck', side_effect=RuntimeError('unhealthy')): + with self.assertRaises(RuntimeError): + deploy.publish(self.config, self.settings) + self.assertEqual((self.target / 'public/app.txt').read_text(), 'first') + self.assertFalse((self.target / 'public/new.txt').exists()) + self.assertEqual((Path(self.config['state']) / 'backend.json').read_text(), state) + + def test_deletion_only_removes_previously_deployed_files(self): + self.write('public/app.txt', 'first') + self.write('public/old.txt', 'old') + self.commit() + deploy.publish(self.config, self.settings) + (self.target / 'public/unmanaged.txt').write_text('retain') + (self.repo / 'public/old.txt').unlink() + self.commit() + deploy.publish(self.config, self.settings) + self.assertFalse((self.target / 'public/old.txt').exists()) + self.assertTrue((self.target / 'public/unmanaged.txt').exists()) + + def test_frontend_deploys_source_instead_of_stale_dist(self): + self.settings['name'] = 'frontend' + self.write('index.html', 'new screen') + self.write('dist/index.html', 'stale build') + self.write('js/app.js', 'export const value = 1;') + self.commit() + deploy.publish(self.config, self.settings) + self.assertEqual((self.target / 'index.html').read_text(), 'new screen') + self.assertFalse((self.target / 'dist').exists()) + + def test_rejects_paths_outside_the_site(self): + with self.assertRaises(RuntimeError): + deploy.destination(self.target, '../outside') + + +if __name__ == '__main__': + unittest.main()