feat: 配置主分支低权限自动部署、备份与失败回退
这个提交包含在:
32
ops/README.md
普通文件
32
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`。
|
||||
200
ops/auto_deploy.py
普通文件
200
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()
|
||||
19
ops/grain-auto-deploy.service
普通文件
19
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
|
||||
11
ops/grain-auto-deploy.timer
普通文件
11
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
|
||||
106
ops/test_auto_deploy.py
普通文件
106
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()
|
||||
在新工单中引用
屏蔽一个用户