mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-18 03:03:49 +08:00
整理 NAS 部署目录并归档迁移工具
更新 Compose 和部署更新脚本以使用新的状态、凭据及日历目录布局;新增目录迁移、诊断和历史清理工具及本机演练测试,并记录 NAS 迁移与清理结果。
This commit is contained in:
1 parent
1af63ff83a
commit
6e2a53ad20
14 files changed
+838
-8
No files matched your search
@@ -9,7 +9,7 @@ services:
|
||||
MI_FITNESS_ALLOWED_HOSTS: localhost,127.0.0.1,192.168.31.201
|
||||
MI_FITNESS_WEB_PASSWORD: ${MI_FITNESS_WEB_PASSWORD:?尚未初始化网页访问密码}
|
||||
volumes:
|
||||
- ./calendar:/calendar
|
||||
- ./web/calendar:/calendar
|
||||
ports: !override
|
||||
- '127.0.0.1:18322:8322'
|
||||
- '192.168.31.201:18322:8322'
|
||||
|
||||
+4
-4
@@ -8,8 +8,8 @@ services:
|
||||
MI_FITNESS_ADMIN_KEY: ${MI_FITNESS_ADMIN_KEY:?需在本地生成管理员密钥}
|
||||
MI_FITNESS_ALLOWED_HOSTS: localhost,127.0.0.1,backend
|
||||
volumes:
|
||||
- ./state:/state
|
||||
- ./secrets/keyring_master:/run/secrets/keyring_master:ro
|
||||
- ./shared/state:/state
|
||||
- ./backend/secrets/keyring_master:/run/secrets/keyring_master:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8321/', timeout=3).close()"]
|
||||
interval: 15s
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
image: local/mi-fitness-nas:20261016
|
||||
command: ["worker"]
|
||||
volumes:
|
||||
- ./state:/state
|
||||
- ./shared/state:/state
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
command: ["mcp"]
|
||||
profiles: [chatgpt]
|
||||
volumes:
|
||||
- ./state/data:/health:ro
|
||||
- ./shared/state/data:/health:ro
|
||||
user: '1000:1001'
|
||||
read_only: true
|
||||
tmpfs:
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$script = Join-Path $PSScriptRoot 'cleanup_history.py'
|
||||
$remote = '/tmp/mi-fitness-cleanup-history-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.py'
|
||||
& scp $script "fn:$remote"
|
||||
if ($LASTEXITCODE -ne 0) { throw '清理工具上传失败' }
|
||||
$expected = (Get-FileHash -LiteralPath $script -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$actual = (& ssh fn "sha256sum '$remote'") -split '\s+'
|
||||
if ($LASTEXITCODE -ne 0 -or $actual[0] -ne $expected) { throw '传输校验失败' }
|
||||
Write-Host '仅删除已列出的过时归档。保留运行数据、最近成功迁移记录和当前配置备份,不重启容器。'
|
||||
& ssh -t fn "sudo python3 '$remote'"
|
||||
$code = $LASTEXITCODE
|
||||
$directory = Join-Path $PSScriptRoot '../../artifacts/directory-migration'
|
||||
New-Item -ItemType Directory -Path $directory -Force | Out-Null
|
||||
@{exit_code=$code;completed_at=(Get-Date -Format o)} | ConvertTo-Json |
|
||||
Set-Content -LiteralPath (Join-Path $directory 'cleanup-terminal-result.local.json') -Encoding utf8NoBOM
|
||||
if ($code -ne 0) { throw '清理未完成,请保留输出。' }
|
||||
Write-Host '清理完成。'
|
||||
@@ -0,0 +1,13 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$remote = '/tmp/mi-fitness-migration-diagnose-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.py'
|
||||
& scp (Join-Path $PSScriptRoot 'diagnose.py') "fn:$remote"
|
||||
if ($LASTEXITCODE -ne 0) { throw '诊断脚本上传失败' }
|
||||
Write-Host '此次仅收集容器状态、迁移时事件和候选配置检查,不迁移、不重启。'
|
||||
$output = & ssh -t fn "sudo python3 '$remote'"
|
||||
$code = $LASTEXITCODE
|
||||
$output | ForEach-Object { Write-Host $_ }
|
||||
$directory = Join-Path $PSScriptRoot '../../artifacts/directory-migration'
|
||||
New-Item -ItemType Directory -Force -Path $directory | Out-Null
|
||||
$output | Set-Content -LiteralPath (Join-Path $directory 'diagnose.local.txt') -Encoding utf8NoBOM
|
||||
if ($code -ne 0) { throw '诊断未完成,请保留输出。' }
|
||||
Write-Host '诊断结果已保存到本机,可以回到聊天回复“已完成”。'
|
||||
@@ -0,0 +1,26 @@
|
||||
param([switch]$Run)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$migration = Join-Path $PSScriptRoot 'migrate.py'
|
||||
$check = Join-Path $PSScriptRoot '../query-summary/check-ready.py'
|
||||
if (-not $Run) { throw '仅在用户已批准目录迁移后使用 -Run。' }
|
||||
$remote = '/tmp/mi-fitness-directory-migration-' + (Get-Date -Format 'yyyyMMdd-HHmmss')
|
||||
& ssh fn "umask 077; mkdir '$remote'"
|
||||
if ($LASTEXITCODE -ne 0) { throw '创建传输目录失败' }
|
||||
foreach ($file in @($migration, $check)) {
|
||||
& scp $file "fn:$remote/"
|
||||
if ($LASTEXITCODE -ne 0) { throw '迁移工具上传失败' }
|
||||
$name = Split-Path $file -Leaf
|
||||
$expected = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$actual = (& ssh fn "sha256sum '$remote/$name'") -split '\s+'
|
||||
if ($LASTEXITCODE -ne 0 -or $actual[0] -ne $expected) { throw '传输校验未通过' }
|
||||
}
|
||||
Write-Host '仅整理健康项目目录并重建三个健康容器;不变更镜像、密码或端口。'
|
||||
Write-Host '如出现 sudo 提示,请仅在此窗口输入 NAS 密码。'
|
||||
& ssh -t fn "sudo python3 '$remote/migrate.py'"
|
||||
$result = $LASTEXITCODE
|
||||
$receipt = Join-Path $PSScriptRoot '../../artifacts/directory-migration'
|
||||
New-Item -ItemType Directory -Path $receipt -Force | Out-Null
|
||||
@{ exit_code=$result; completed_at=(Get-Date -Format o); remote_tools=$remote } |
|
||||
ConvertTo-Json | Set-Content -LiteralPath (Join-Path $receipt 'terminal-result.local.json') -Encoding utf8NoBOM
|
||||
if ($result -ne 0) { throw '迁移未完成,请保留终端输出。' }
|
||||
Write-Host '项目目录迁移完成,网站地址和密码保持不变。'
|
||||
@@ -0,0 +1,94 @@
|
||||
"""只清理明确列出的旧归档,不操作容器、镜像或运行数据。"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
|
||||
ROOT = Path('/vol2/1000/Soft/mi-fitness-health')
|
||||
KEEP = 'directory-migration-retry-1792227666562967051'
|
||||
OBSOLETE = ['initial-install', 'password-change', 'rollback-configs', 'ui4', 'ui5', 'ui6', 'ui7',
|
||||
'directory-migration', 'directory-migration-retry-1792227359874267909']
|
||||
EXPECTED = {'compose.yml': 'b5c965c653948e97e02e22774bd15253dbde74d5e25616fa0115f047a36556dd',
|
||||
'compose.override.yml': 'd77485b3d3177def55c200478ba9e4645689f8476f46c13a47487be22edcd1c1'}
|
||||
|
||||
|
||||
def candidates(root):
|
||||
root = Path(root)
|
||||
archive = root/'archive'
|
||||
if root.is_symlink() or root.resolve() != root or archive.is_symlink() or archive.resolve().parent != root:
|
||||
raise RuntimeError('项目或归档路径异常')
|
||||
paths = []
|
||||
for name in OBSOLETE:
|
||||
path = archive/name
|
||||
if path.is_symlink() or path.resolve().parent != archive or not path.is_dir():
|
||||
raise RuntimeError('历史目录缺失或路径异常,未清理任何文件')
|
||||
for child in path.rglob('*'):
|
||||
if not (stat.S_ISREG(child.lstat().st_mode) or stat.S_ISDIR(child.lstat().st_mode)):
|
||||
raise RuntimeError('历史目录包含链接或特殊文件,未清理任何文件')
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() != 0:
|
||||
raise RuntimeError('需要通过 sudo 执行')
|
||||
paths = candidates(ROOT)
|
||||
keep = ROOT/'archive'/KEEP
|
||||
if keep.is_symlink() or json.loads((keep/'result.json').read_text(encoding='utf-8'))['status'] != 'complete':
|
||||
raise RuntimeError('成功迁移记录异常,停止')
|
||||
for name, expected in EXPECTED.items():
|
||||
path = ROOT/name
|
||||
if path.is_symlink() or hashlib.sha256(path.read_bytes()).hexdigest() != expected:
|
||||
raise RuntimeError('当前配置已变化,停止')
|
||||
ids = subprocess.check_output(['docker', 'ps', '-aq']).decode().split()
|
||||
items = json.loads(subprocess.check_output(['docker','inspect',*ids],stderr=subprocess.PIPE)) if ids else []
|
||||
for item in items:
|
||||
for mount in item.get('Mounts', []):
|
||||
if mount.get('Type') != 'bind':
|
||||
continue
|
||||
source = Path(mount['Source']).resolve()
|
||||
if any(source == p or source.is_relative_to(p) or p.is_relative_to(source) for p in paths):
|
||||
raise RuntimeError('容器引用历史归档,停止')
|
||||
services = {'/mi-fitness-health-'+x+'-1' for x in ['web','backend','worker']}
|
||||
current = [i for i in items if i['Name'] in services]
|
||||
if len(current)!=3 or not all(i['State']['Running'] for i in current):
|
||||
raise RuntimeError('健康容器状态异常,停止')
|
||||
snapshots = [(i['Id'], i['State']['StartedAt']) for i in current]
|
||||
record = {'at': datetime.now(timezone.utc).isoformat(), 'directories':[], 'bytes_removed':0, 'files_removed':0}
|
||||
for path in paths:
|
||||
files = [p for p in path.rglob('*') if p.is_file()]
|
||||
size = sum(p.stat().st_size for p in files)
|
||||
record['directories'].append({'name':path.name,'files':len(files),'bytes':size})
|
||||
record['bytes_removed'] += size
|
||||
record['files_removed'] += len(files)
|
||||
# 保留当前可用配置,而非仅留下引用旧路径的迁移前配置。
|
||||
for name in EXPECTED:
|
||||
shutil.copy2(ROOT/name, keep/('current-'+name))
|
||||
(keep/'cleanup-plan.json').write_text(json.dumps(record,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
|
||||
for path in paths:
|
||||
if path.is_symlink() or path.resolve().parent != ROOT/'archive':
|
||||
raise RuntimeError('删除前路径核对失败,停止')
|
||||
shutil.rmtree(path)
|
||||
print('已清理:'+path.name,flush=True)
|
||||
after = json.loads(subprocess.check_output(['docker','inspect',*[i['Id'] for i in current]],stderr=subprocess.PIPE))
|
||||
if snapshots != [(i['Id'],i['State']['StartedAt']) for i in after] or not all(i['State']['Running'] for i in after):
|
||||
raise RuntimeError('容器状态发生变化,需核对')
|
||||
for name, expected in EXPECTED.items():
|
||||
if hashlib.sha256((ROOT/name).read_bytes()).hexdigest()!=expected:
|
||||
raise RuntimeError('当前配置发生变化,需核对')
|
||||
record['status']='complete'
|
||||
record['containers_unchanged']=True
|
||||
(keep/'cleanup-result.json').write_text(json.dumps(record,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
|
||||
print('历史文件清理完成,释放约 %.1f MiB;三个容器未重启。' % (record['bytes_removed']/1024**2),flush=True)
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
print(str(error) if type(error) is RuntimeError else type(error).__name__,flush=True)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""只读诊断迁移失败;不停止容器,不读取健康数据或输出环境凭据。"""
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path('/vol2/1000/Soft/mi-fitness-health')
|
||||
NAMES = ['mi-fitness-health-' + x + '-1' for x in ['backend', 'web', 'worker']]
|
||||
|
||||
|
||||
def run(*args):
|
||||
return subprocess.check_output(args, stderr=subprocess.PIPE, timeout=90)
|
||||
|
||||
|
||||
items = json.loads(run('docker', 'inspect', *NAMES))
|
||||
for item in items:
|
||||
print(json.dumps({'container': item['Name'], 'status': item['State']['Status'],
|
||||
'health': item['State'].get('Health', {}).get('Status'),
|
||||
'mounts': [{'source': m.get('Source'), 'destination': m['Destination']}
|
||||
for m in item['Mounts'] if m.get('Type') == 'bind']}, ensure_ascii=False))
|
||||
events = run('docker', 'events', '--since', '2026-10-17T16:40:00+08:00',
|
||||
'--until', '2026-10-17T16:45:00+08:00', '--filter', 'type=container',
|
||||
'--filter', 'label=com.docker.compose.project=mi-fitness-health', '--format', '{{json .}}')
|
||||
for line in events.splitlines():
|
||||
event = json.loads(line)
|
||||
action = event.get('Action', '')
|
||||
if action in {'create', 'start', 'stop', 'die', 'destroy', 'kill'} or action.startswith('health_status'):
|
||||
attrs = event.get('Actor', {}).get('Attributes', {})
|
||||
print(json.dumps({'event': action, 'time': event.get('time'),
|
||||
'name': attrs.get('name'), 'exit_code': attrs.get('exitCode')}, ensure_ascii=False))
|
||||
with tempfile.TemporaryDirectory(prefix='health-migration-readonly-') as directory:
|
||||
base = (ROOT/'compose.yml').read_text(encoding='utf-8')
|
||||
over = (ROOT/'compose.override.yml').read_text(encoding='utf-8')
|
||||
base = base.replace('./state:', './shared/state:').replace('./state/data:', './shared/state/data:').replace('./secrets/', './backend/secrets/')
|
||||
over = over.replace('./calendar:', './web/calendar:')
|
||||
paths = []
|
||||
for name, text in [('compose.yml', base), ('compose.override.yml', over)]:
|
||||
path = Path(directory)/name
|
||||
path.write_text(text, encoding='utf-8')
|
||||
paths.extend(['-f', str(path)])
|
||||
try:
|
||||
run('docker', 'compose', '--project-directory', str(ROOT), '--env-file', str(ROOT/'.env'),
|
||||
'-p', 'mi-fitness-health', *paths, 'config', '--quiet')
|
||||
print('候选挂载配置静态解析通过(未应用)。')
|
||||
except subprocess.CalledProcessError:
|
||||
print('候选挂载配置静态解析失败。')
|
||||
print('只读诊断完成。')
|
||||
@@ -0,0 +1,296 @@
|
||||
"""已授权的 NAS 目录整理;只移动本项目文件,失败时恢复原路径。"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
ROOT = Path('/vol2/1000/Soft/mi-fitness-health')
|
||||
SERVICES = ['backend', 'web', 'worker']
|
||||
NAMES = ['mi-fitness-health-' + name + '-1' for name in SERVICES]
|
||||
EXPECTED = {
|
||||
'compose.yml': '111fcd84b1740e377db958c459c922dda4191317e121267bdf3146a75e864ac0',
|
||||
'compose.override.yml': '054b4541a3a2e50d46473061822c637c4694fa210414d05b2b6aa43deabea0d3',
|
||||
}
|
||||
|
||||
|
||||
def digest(path):
|
||||
with path.open('rb') as stream:
|
||||
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||
|
||||
|
||||
def inventory(path):
|
||||
"""仅在内存中核对内容和权限,不输出凭据或健康文件名。"""
|
||||
result = {}
|
||||
for item in [path, *sorted(path.rglob('*'))] if path.is_dir() else [path]:
|
||||
info = item.lstat()
|
||||
if not (stat.S_ISDIR(info.st_mode) or stat.S_ISREG(info.st_mode)):
|
||||
raise RuntimeError('待迁移目录含链接或特殊文件,停止')
|
||||
result[str(item.relative_to(path))] = (
|
||||
stat.S_IFMT(info.st_mode), stat.S_IMODE(info.st_mode),
|
||||
info.st_uid, info.st_gid, digest(item) if item.is_file() else None,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def environment_values(item):
|
||||
result = {}
|
||||
for entry in item['Config'].get('Env', []):
|
||||
key, separator, value = entry.partition('=')
|
||||
if not separator or key in result:
|
||||
raise RuntimeError('环境变量含缺失值或重复名称,停止核对')
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
class Migration:
|
||||
def __init__(self, root=ROOT):
|
||||
self.root = Path(root)
|
||||
self.moves = []
|
||||
self.snapshots = {}
|
||||
self.original = {}
|
||||
self.stopped = False
|
||||
self.journal = self.root / 'archive' / 'directory-migration'
|
||||
self.stage = 'preflight'
|
||||
|
||||
def run(self, *args):
|
||||
# Docker inspect/config 的完整结果不得输出到终端(可能包含环境凭据)。
|
||||
try:
|
||||
return subprocess.check_output(list(args), stderr=subprocess.PIPE)
|
||||
except subprocess.CalledProcessError as error:
|
||||
if self.journal.is_dir():
|
||||
detail = (error.stderr or b'').decode('utf-8', errors='replace')[-16000:]
|
||||
for item in getattr(self, 'before', []):
|
||||
for entry in item['Config'].get('Env', []):
|
||||
key, _, value = entry.partition('=')
|
||||
if len(value) >= 4 and any(word in key.upper() for word in ['PASSWORD', 'TOKEN', 'SECRET', 'KEY']):
|
||||
detail = detail.replace(value, '[REDACTED]')
|
||||
path = self.journal / 'errors.log'
|
||||
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
with os.fdopen(descriptor, 'a', encoding='utf-8') as stream:
|
||||
stream.write(json.dumps({'stage': self.stage, 'exit_code': error.returncode,
|
||||
'stderr': detail}, ensure_ascii=False)+'\n')
|
||||
raise
|
||||
|
||||
def compose(self, *args):
|
||||
return self.run('docker', 'compose', '--project-directory', str(self.root),
|
||||
'-p', 'mi-fitness-health', '-f', str(self.root / 'compose.yml'),
|
||||
'-f', str(self.root / 'compose.override.yml'), *args)
|
||||
|
||||
def inspect(self):
|
||||
return json.loads(self.run('docker', 'inspect', *NAMES))
|
||||
|
||||
def mapping(self):
|
||||
pairs = [('state', 'shared/state'), ('secrets', 'backend/secrets'),
|
||||
('calendar', 'web/calendar')]
|
||||
for name, destination in [
|
||||
('.mi-fitness-health-stage-20261016-212720', 'initial-install'),
|
||||
('.mi-fitness-health-password-change', 'password-change'),
|
||||
*[('.mi-fitness-health-ui%d-update' % n, 'ui%d' % n) for n in range(4, 8)],
|
||||
]:
|
||||
pairs.append((self.root.parent / name, 'archive/' + destination))
|
||||
for item in sorted(self.root.iterdir()):
|
||||
if item.name.startswith(('compose.override.pre-', '.env.pre-')):
|
||||
pairs.append((item, 'archive/rollback-configs/' + item.name))
|
||||
return [(self.root / source, self.root / target) for source, target in pairs]
|
||||
|
||||
def preflight(self):
|
||||
if self.root.is_symlink() or self.root.resolve() != self.root:
|
||||
raise RuntimeError('项目路径含符号链接,停止')
|
||||
for name, expected in EXPECTED.items():
|
||||
path = self.root / name
|
||||
if path.is_symlink() or digest(path) != expected:
|
||||
raise RuntimeError('当前配置已变化,需重新核对')
|
||||
self.original[name] = path.read_bytes()
|
||||
self.pairs = self.mapping()
|
||||
if self.journal.exists():
|
||||
result = self.journal / 'result.json'
|
||||
if self.journal.is_symlink() or result.is_symlink() or not result.is_file() or json.loads(result.read_text(encoding='utf-8')).get('status') != 'rolled_back':
|
||||
raise RuntimeError('已有未回退的迁移记录,拒绝重复运行')
|
||||
self.journal = self.root / 'archive' / ('directory-migration-retry-' + str(time.time_ns()))
|
||||
for name in ['shared', 'backend', 'web', 'worker']:
|
||||
path = self.root / name
|
||||
if path.exists() and (path.is_symlink() or not path.is_dir() or any(path.iterdir())):
|
||||
raise RuntimeError('容器专属目标目录不为空,停止')
|
||||
for source, target in self.pairs:
|
||||
if not source.exists() or source.is_symlink() or target.exists():
|
||||
raise RuntimeError('迁移源缺失或目标已存在,停止')
|
||||
if source.resolve().parent not in (self.root, self.root.parent):
|
||||
raise RuntimeError('迁移源超出授权目录')
|
||||
if not target.resolve().is_relative_to(self.root):
|
||||
raise RuntimeError('迁移目标超出项目目录')
|
||||
if source.stat().st_dev != self.root.stat().st_dev:
|
||||
raise RuntimeError('跨文件系统移动需另行处理,停止')
|
||||
self.before = self.inspect()
|
||||
expected_images = ['local/mi-fitness-nas:20261017-summary8',
|
||||
'local/mi-fitness-nas:20261017-summary8',
|
||||
'local/mi-fitness-nas:20261017-audit3']
|
||||
for item, name, image in zip(self.before, NAMES, expected_images):
|
||||
if item['Name'] != '/' + name or item['Config']['Image'] != image:
|
||||
raise RuntimeError('运行容器或镜像与已确认版本不符')
|
||||
if not item['State']['Running']:
|
||||
raise RuntimeError('原容器未运行,需先核对')
|
||||
labels = item['Config']['Labels']
|
||||
if Path(labels['com.docker.compose.project.working_dir']) != self.root:
|
||||
raise RuntimeError('容器项目目录不符')
|
||||
files = labels['com.docker.compose.project.config_files'].split(',')
|
||||
if set(files) != {str(self.root / n) for n in EXPECTED}:
|
||||
raise RuntimeError('存在额外 Compose 配置,停止')
|
||||
# 其他项目若引用待移动路径,不能继续。
|
||||
ids = self.run('docker', 'ps', '-aq').decode().split()
|
||||
containers = json.loads(self.run('docker', 'inspect', *ids)) if ids else []
|
||||
for item in containers:
|
||||
if item['Name'] in ['/' + n for n in NAMES]:
|
||||
continue
|
||||
for mount in item.get('Mounts', []):
|
||||
if mount.get('Type') != 'bind':
|
||||
continue
|
||||
path = Path(mount.get('Source', '/'))
|
||||
for source, _ in self.pairs:
|
||||
if path == source or path.is_relative_to(source) or source.is_relative_to(path):
|
||||
raise RuntimeError('其他容器引用待迁移路径,停止')
|
||||
self.compose('config', '--quiet')
|
||||
print('配置、容器引用和迁移路径预检通过。', flush=True)
|
||||
|
||||
def write_configs(self):
|
||||
for name, content in self.original.items():
|
||||
text = content.decode('utf-8')
|
||||
if name == 'compose.yml':
|
||||
if text.count('./state:') != 2 or text.count('./state/data:') != 1 or text.count('./secrets/') != 1:
|
||||
raise RuntimeError('基础配置挂载数量不符')
|
||||
text = text.replace('./state:', './shared/state:').replace('./state/data:', './shared/state/data:')
|
||||
text = text.replace('./secrets/', './backend/secrets/')
|
||||
else:
|
||||
if text.count('./calendar:') != 1:
|
||||
raise RuntimeError('日历挂载数量不符')
|
||||
text = text.replace('./calendar:', './web/calendar:')
|
||||
(self.root / name).write_bytes(text.encode('utf-8'))
|
||||
|
||||
def ready(self):
|
||||
deadline = time.monotonic() + 180
|
||||
while time.monotonic() < deadline:
|
||||
items = self.inspect()
|
||||
if all(item['State'].get('Running') and
|
||||
item['State'].get('Health', {}).get('Status', 'healthy') == 'healthy'
|
||||
for item in items):
|
||||
return items
|
||||
time.sleep(3)
|
||||
raise RuntimeError('容器未按时就绪')
|
||||
|
||||
def verify(self):
|
||||
after = self.ready()
|
||||
replacements = {str(self.root / 'state'): str(self.root / 'shared/state'),
|
||||
str(self.root / 'secrets/keyring_master'): str(self.root / 'backend/secrets/keyring_master'),
|
||||
str(self.root / 'calendar'): str(self.root / 'web/calendar')}
|
||||
def mounts(item, translated=False):
|
||||
return sorted((replacements.get(m['Source'], m['Source']) if translated else m['Source'],
|
||||
m['Destination'], m['RW']) for m in item['Mounts'])
|
||||
comparisons = []
|
||||
for old, new in zip(self.before, after):
|
||||
old_env, new_env = environment_values(old), environment_values(new)
|
||||
changed = sorted(key for key in old_env.keys() | new_env.keys()
|
||||
if key not in old_env or key not in new_env or old_env[key] != new_env[key])
|
||||
comparisons.append({'container': old['Name'], 'same_image': old['Image'] == new['Image'],
|
||||
'same_environment_values': not changed, 'changed_environment_names': changed,
|
||||
'environment_order_changed': old['Config'].get('Env') != new['Config'].get('Env')})
|
||||
if self.journal.is_dir():
|
||||
(self.journal/'verification.json').write_text(json.dumps(comparisons, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
|
||||
for old, new, comparison in zip(self.before, after, comparisons):
|
||||
if not comparison['same_image']:
|
||||
raise RuntimeError('镜像标识发生变化:' + old['Name'])
|
||||
if not comparison['same_environment_values']:
|
||||
raise RuntimeError('环境变量值发生变化:' + old['Name'] + ' ' + ','.join(comparison['changed_environment_names']))
|
||||
if comparison['environment_order_changed']:
|
||||
print(old['Name'] + ' 环境变量值逐项一致,仅排列顺序变化。', flush=True)
|
||||
for key in ['PortBindings', 'ReadonlyRootfs', 'RestartPolicy']:
|
||||
if old['HostConfig'][key] != new['HostConfig'][key]:
|
||||
raise RuntimeError('端口或运行策略发生额外变化')
|
||||
if mounts(old, True) != mounts(new):
|
||||
raise RuntimeError('挂载与迁移计划不符')
|
||||
# 复用现有静态资源、鉴权和代理检查,不查询真实健康数据。
|
||||
self.stage = 'web-readiness'
|
||||
deadline = time.monotonic() + 90
|
||||
while True:
|
||||
try:
|
||||
self.run('python3', str(Path(__file__).with_name('check-ready.py')))
|
||||
break
|
||||
except subprocess.CalledProcessError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise
|
||||
print('网页仍在启动,等待实际 HTTP 鉴权和代理就绪。', flush=True)
|
||||
time.sleep(3)
|
||||
print('三个容器已就绪;镜像、密码环境、端口及迁移挂载核对通过。', flush=True)
|
||||
|
||||
def execute(self):
|
||||
self.preflight()
|
||||
self.journal.mkdir(parents=True, mode=0o700)
|
||||
for name, content in self.original.items():
|
||||
path = self.journal / name
|
||||
path.write_bytes(content)
|
||||
shutil.copystat(self.root / name, path)
|
||||
# 两端目录仅在项目内创建,不改原有运行文件权限。
|
||||
for name in ['shared', 'backend', 'web', 'worker']:
|
||||
(self.root / name).mkdir(mode=0o700, exist_ok=True)
|
||||
shutil.copystat(self.root, self.root / name)
|
||||
if hasattr(os, 'chown'):
|
||||
info = self.root.stat()
|
||||
os.chown(self.root / name, info.st_uid, info.st_gid)
|
||||
try:
|
||||
self.stopped = True
|
||||
self.stage = 'stop-original-services'
|
||||
self.compose('stop', '-t', '60', 'worker', 'web', 'backend')
|
||||
if any(item['State']['Running'] for item in self.inspect()):
|
||||
raise RuntimeError('容器未完全停止')
|
||||
# 停止写入后再计算哈希;文件、权限、属主逐项核对。
|
||||
self.stage = 'move-and-verify-files'
|
||||
for source, target in self.pairs:
|
||||
self.snapshots[str(source)] = inventory(source)
|
||||
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
source.rename(target)
|
||||
self.moves.append((source, target))
|
||||
if inventory(target) != self.snapshots[str(source)]:
|
||||
raise RuntimeError('移动后文件或权限校验失败')
|
||||
print('全部文件移动完成,内容校验及权限核对通过。', flush=True)
|
||||
self.write_configs()
|
||||
self.stage = 'validate-migrated-config'
|
||||
self.compose('config', '--quiet')
|
||||
self.stage = 'recreate-services'
|
||||
self.compose('up', '-d', '--no-build', '--pull', 'never', *SERVICES)
|
||||
self.stage = 'verify-services'
|
||||
self.verify()
|
||||
receipt = {'status': 'complete', 'file_trees_verified': len(self.moves),
|
||||
'services': SERVICES, 'paths': [[str(a), str(b)] for a, b in self.moves]}
|
||||
(self.journal / 'result.json').write_text(json.dumps(receipt, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
|
||||
print('项目目录迁移完成。', flush=True)
|
||||
except BaseException:
|
||||
failed_stage = self.stage
|
||||
print('迁移未通过(阶段:' + failed_stage + '),正在恢复原路径和配置。', flush=True)
|
||||
self.stage = 'rollback'
|
||||
if self.stopped:
|
||||
self.compose('stop', '-t', '60', 'worker', 'web', 'backend')
|
||||
for source, target in reversed(self.moves):
|
||||
if source.exists():
|
||||
raise RuntimeError('回退源路径被占用,已停止,需人工核对')
|
||||
target.rename(source)
|
||||
for name, content in self.original.items():
|
||||
(self.root / name).write_bytes(content)
|
||||
self.compose('up', '-d', '--no-build', '--pull', 'never', *SERVICES)
|
||||
self.ready()
|
||||
(self.journal / 'result.json').write_text(json.dumps({'status': 'rolled_back', 'failed_stage': failed_stage})+'\n', encoding='utf-8')
|
||||
print('已恢复原目录、配置并启动三个健康容器。', flush=True)
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if os.geteuid() != 0:
|
||||
raise SystemExit('需要通过 sudo 在 NAS 本机执行')
|
||||
try:
|
||||
Migration().execute()
|
||||
except BaseException as error:
|
||||
# 不打印可能带容器环境变量的子进程输出。
|
||||
detail = str(error) if type(error) is RuntimeError else type(error).__name__
|
||||
print('迁移中止:' + detail, flush=True)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""显式运行的本机真实容器演练,全部使用合成配置,不连接 NAS。"""
|
||||
import ast
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
DOCKER = 'C:/Users/35861/AppData/Local/Programs/DockerDesktop/resources/bin/docker.exe'
|
||||
PROJECT = 'health-directory-local-rebuild'
|
||||
|
||||
|
||||
def main():
|
||||
if sys.argv[1:] != ['--run']:
|
||||
raise SystemExit('仅在本机运行时显式指定 --run')
|
||||
with tempfile.TemporaryDirectory(prefix='health-directory-local-') as directory:
|
||||
root = Path(directory)
|
||||
for name in ['state', 'secrets', 'calendar']:
|
||||
(root/name).mkdir()
|
||||
(root/'secrets/keyring_master').write_text('synthetic-local-master-value-1234567890123456789', encoding='utf-8')
|
||||
(root/'.env').write_text('MI_FITNESS_ADMIN_KEY=synthetic-local-admin\nMI_FITNESS_WEB_PASSWORD=synthetic-local-password\n', encoding='utf-8')
|
||||
base = (HERE.parent/'compose.yml').read_text(encoding='utf-8').replace('name: mi-fitness-health', 'name: '+PROJECT)
|
||||
over = (HERE.parent/'compose.override.yml').read_text(encoding='utf-8')
|
||||
base = base.replace('./shared/state:', './state:').replace('./shared/state/data:', './state/data:').replace('./backend/secrets/', './secrets/')
|
||||
over = over.replace('./web/calendar:', './calendar:')
|
||||
over = over.replace(" - '127.0.0.1:18322:8322'\n - '192.168.31.201:18322:8322'", " - '127.0.0.1:0:8322'")
|
||||
over += '\nnetworks:\n default:\n internal: true\n'
|
||||
def save():
|
||||
(root/'compose.yml').write_text(base, encoding='utf-8')
|
||||
(root/'compose.override.yml').write_text(over, encoding='utf-8')
|
||||
save()
|
||||
command = [DOCKER, '--context', 'desktop-linux', 'compose', '--project-directory', str(root),
|
||||
'-p', PROJECT, '-f', str(root/'compose.yml'), '-f', str(root/'compose.override.yml')]
|
||||
def compose(*args):
|
||||
return subprocess.check_output([*command, *args], stderr=subprocess.STDOUT)
|
||||
def inspect():
|
||||
ids = compose('ps', '-aq').decode().split()
|
||||
return json.loads(subprocess.check_output([DOCKER, '--context', 'desktop-linux', 'inspect', *ids]))
|
||||
def ready():
|
||||
deadline = time.monotonic()+90
|
||||
while time.monotonic() < deadline:
|
||||
items = inspect()
|
||||
if len(items)==3 and all(i['State']['Running'] and i['State'].get('Health',{}).get('Status','healthy')=='healthy' for i in items):
|
||||
return
|
||||
time.sleep(1)
|
||||
raise RuntimeError('本机容器就绪超时')
|
||||
module = ast.parse((HERE.parent/'query-summary/check-ready.py').read_text(encoding='utf-8'))
|
||||
probe = next(ast.literal_eval(n.value) for n in module.body if isinstance(n, ast.Assign)
|
||||
and any(isinstance(t, ast.Name) and t.id=='probe' for t in n.targets))
|
||||
try:
|
||||
compose('up','-d','--no-build','--pull','never','backend','web','worker')
|
||||
ready()
|
||||
compose('stop','-t','30','worker','web','backend')
|
||||
for source, target in [('state','shared/state'),('secrets','backend/secrets'),('calendar','web/calendar')]:
|
||||
destination=root/target
|
||||
destination.parent.mkdir(exist_ok=True)
|
||||
(root/source).rename(destination)
|
||||
base=base.replace('./state:', './shared/state:').replace('./state/data:', './shared/state/data:').replace('./secrets/', './backend/secrets/')
|
||||
over=over.replace('./calendar:', './web/calendar:')
|
||||
save()
|
||||
compose('config','--quiet')
|
||||
compose('up','-d','--no-build','--pull','never','backend','web','worker')
|
||||
ready()
|
||||
print('容器已标记就绪,立即执行实际网页探针:', flush=True)
|
||||
result=subprocess.run([DOCKER,'--context','desktop-linux','exec','-i',PROJECT+'-web-1','python','-'],
|
||||
input=probe.encode('utf-8'),capture_output=True)
|
||||
print(result.stdout.decode('utf-8',errors='replace'), flush=True)
|
||||
print(result.stderr.decode('utf-8',errors='replace'), flush=True)
|
||||
if result.returncode:
|
||||
print('首次探针失败,五秒后重复同一只读探针以区分启动时序。', flush=True)
|
||||
time.sleep(5)
|
||||
retry=subprocess.run([DOCKER,'--context','desktop-linux','exec','-i',PROJECT+'-web-1','python','-'],
|
||||
input=probe.encode('utf-8'),capture_output=True)
|
||||
print(retry.stdout.decode('utf-8',errors='replace'),flush=True)
|
||||
print(retry.stderr.decode('utf-8',errors='replace'),flush=True)
|
||||
if retry.returncode:
|
||||
raise RuntimeError('本机网页探针持续失败')
|
||||
print('LOCAL_REAL_REBUILD_AND_WEB_PROBE_OK',flush=True)
|
||||
except subprocess.CalledProcessError as error:
|
||||
# 此处仅含本机合成配置,无真实凭据。
|
||||
print(error.output.decode('utf-8',errors='replace'),flush=True)
|
||||
raise
|
||||
finally:
|
||||
compose('down')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,187 @@
|
||||
"""本机合成文件演练;不连接 NAS、不读取真实数据。"""
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import subprocess
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
spec = importlib.util.spec_from_file_location('directory_migration', HERE / 'migrate.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
|
||||
class SimulatedMigration(module.Migration):
|
||||
def __init__(self, root):
|
||||
super().__init__(root)
|
||||
self.items = []
|
||||
self.other = []
|
||||
self.fail_verification = False
|
||||
self.events = []
|
||||
for name, image in zip(module.NAMES, ['summary8', 'summary8', 'audit3']):
|
||||
kind = name.split('-')[-2]
|
||||
mounts = ([('state', '/state', True)] if kind != 'web' else [('calendar', '/calendar', True)])
|
||||
if kind == 'backend':
|
||||
mounts.append(('secrets/keyring_master', '/run/secrets/keyring_master', False))
|
||||
self.items.append({'Name': '/' + name, 'Image': image,
|
||||
'State': {'Running': True, 'Health': {'Status': 'healthy'}},
|
||||
'Config': {'Image': 'local/mi-fitness-nas:20261017-' + image,
|
||||
'Env': ['EXAMPLE=synthetic'], 'Labels': {
|
||||
'com.docker.compose.project.working_dir': str(root),
|
||||
'com.docker.compose.project.config_files': ','.join(str(root / n) for n in module.EXPECTED)}},
|
||||
'HostConfig': {'PortBindings': {}, 'ReadonlyRootfs': True, 'RestartPolicy': {'Name': 'unless-stopped'}},
|
||||
'Mounts': [{'Source': str(root / p), 'Destination': d, 'RW': rw, 'Type': 'bind'} for p, d, rw in mounts]})
|
||||
|
||||
def run(self, *args):
|
||||
if args[:3] == ('docker', 'ps', '-aq'):
|
||||
return b'one\n'
|
||||
if args[:2] == ('docker', 'inspect'):
|
||||
return json.dumps(self.items + self.other).encode()
|
||||
if args[0] == 'python3':
|
||||
return b''
|
||||
raise AssertionError('unexpected command')
|
||||
|
||||
def inspect(self):
|
||||
return copy.deepcopy(self.items)
|
||||
|
||||
def compose(self, *args):
|
||||
self.events.append(args[0])
|
||||
if args[0] == 'stop':
|
||||
for item in self.items:
|
||||
item['State']['Running'] = False
|
||||
if args[0] == 'up':
|
||||
migrated = './shared/state:' in (self.root / 'compose.yml').read_text(encoding='utf-8')
|
||||
for item in self.items:
|
||||
item['State']['Running'] = True
|
||||
for mount in item['Mounts']:
|
||||
mount['Source'] = str(self.root / ({'/state': 'shared/state' if migrated else 'state',
|
||||
'/calendar': 'web/calendar' if migrated else 'calendar',
|
||||
'/run/secrets/keyring_master': 'backend/secrets/keyring_master' if migrated else 'secrets/keyring_master'}[mount['Destination']]))
|
||||
return b''
|
||||
|
||||
def verify(self):
|
||||
if self.fail_verification:
|
||||
raise RuntimeError('synthetic readiness failure')
|
||||
super().verify()
|
||||
|
||||
|
||||
class MigrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name).resolve() / 'mi-fitness-health'
|
||||
self.root.mkdir()
|
||||
for name in module.EXPECTED:
|
||||
data = (HERE.parent / name).read_bytes().replace(b'\r\n', b'\n')
|
||||
data = data.replace(b'./shared/state:', b'./state:').replace(b'./shared/state/data:', b'./state/data:')
|
||||
data = data.replace(b'./backend/secrets/', b'./secrets/').replace(b'./web/calendar:', b'./calendar:')
|
||||
(self.root / name).write_bytes(data)
|
||||
for folder in ['state', 'secrets', 'calendar']:
|
||||
(self.root / folder).mkdir()
|
||||
(self.root / folder / 'synthetic.txt').write_text('synthetic-only', encoding='utf-8')
|
||||
for name in ['.mi-fitness-health-stage-20261016-212720', '.mi-fitness-health-password-change',
|
||||
*['.mi-fitness-health-ui%d-update' % n for n in range(4, 8)]]:
|
||||
path = self.root.parent / name
|
||||
path.mkdir()
|
||||
(path / 'example.txt').write_text('archive-example', encoding='utf-8')
|
||||
(self.root / 'compose.override.pre-synthetic.yml').write_text('old-config', encoding='utf-8')
|
||||
self.migration = SimulatedMigration(self.root)
|
||||
|
||||
def test_success_keeps_contents_permissions_and_shared_mounts(self):
|
||||
original = module.inventory(self.root / 'state')
|
||||
self.migration.execute()
|
||||
self.assertEqual(module.inventory(self.root / 'shared/state'), original)
|
||||
self.assertFalse((self.root / 'state').exists())
|
||||
self.assertTrue((self.root / 'archive/ui7/example.txt').exists())
|
||||
self.assertTrue((self.root / 'archive/rollback-configs/compose.override.pre-synthetic.yml').exists())
|
||||
self.assertEqual(json.loads((self.migration.journal / 'result.json').read_text())['status'], 'complete')
|
||||
before = (self.root / 'compose.yml').read_text(encoding='utf-8')
|
||||
self.assertIn('./shared/state/data:/health:ro', before)
|
||||
|
||||
def test_failed_readiness_restores_all_original_paths_and_configs(self):
|
||||
self.migration.fail_verification = True
|
||||
before = (self.root / 'compose.yml').read_bytes()
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.migration.execute()
|
||||
self.assertEqual((self.root / 'compose.yml').read_bytes(), before)
|
||||
self.assertTrue((self.root / 'state/synthetic.txt').exists())
|
||||
self.assertFalse((self.root / 'shared/state').exists())
|
||||
self.assertTrue((self.root.parent / '.mi-fitness-health-ui7-update/example.txt').exists())
|
||||
self.assertTrue(all(item['State']['Running'] for item in self.migration.items))
|
||||
|
||||
def test_other_container_reference_stops_before_mutation(self):
|
||||
self.migration.other = [{'Name': '/other-production', 'Mounts': [
|
||||
{'Type': 'bind', 'Source': str(self.root / 'state/data')}]}]
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.migration.execute()
|
||||
self.assertNotIn('stop', self.migration.events)
|
||||
self.assertTrue((self.root / 'state').exists())
|
||||
|
||||
def test_changed_config_stops_before_mutation(self):
|
||||
with (self.root / 'compose.yml').open('a', encoding='utf-8') as stream:
|
||||
stream.write('# changed\n')
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.migration.execute()
|
||||
self.assertNotIn('stop', self.migration.events)
|
||||
|
||||
def test_web_probe_waits_for_http_after_container_reports_running(self):
|
||||
self.migration.preflight()
|
||||
# 挂载保持未迁移时,同样模拟预期路径转换以独立验证网页启动等待。
|
||||
self.migration.before = []
|
||||
attempts = []
|
||||
original_run = self.migration.run
|
||||
def delayed_probe(*args):
|
||||
if args[0] == 'python3':
|
||||
attempts.append(1)
|
||||
if len(attempts) < 3:
|
||||
raise subprocess.CalledProcessError(1, args, stderr=b'ConnectionRefusedError')
|
||||
return b''
|
||||
return original_run(*args)
|
||||
self.migration.run = delayed_probe
|
||||
with patch.object(module.time, 'sleep'):
|
||||
self.migration.verify()
|
||||
self.assertEqual(len(attempts), 3)
|
||||
|
||||
def test_retry_preserves_previous_rollback_record(self):
|
||||
self.migration.fail_verification = True
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.migration.execute()
|
||||
previous = self.migration.journal
|
||||
retry = SimulatedMigration(self.root)
|
||||
retry.execute()
|
||||
self.assertNotEqual(retry.journal, previous)
|
||||
self.assertEqual(json.loads((previous/'result.json').read_text())['status'], 'rolled_back')
|
||||
self.assertEqual(json.loads((retry.journal/'result.json').read_text())['status'], 'complete')
|
||||
|
||||
def test_environment_order_does_not_count_as_value_change(self):
|
||||
for item in self.migration.items:
|
||||
item['Config']['Env'] = ['EXAMPLE=synthetic', 'SECOND=unchanged']
|
||||
original_compose = self.migration.compose
|
||||
def recreate_with_reordered_environment(*args):
|
||||
result = original_compose(*args)
|
||||
if args[0] == 'up':
|
||||
for item in self.migration.items:
|
||||
item['Config']['Env'].reverse()
|
||||
return result
|
||||
self.migration.compose = recreate_with_reordered_environment
|
||||
self.migration.execute()
|
||||
self.assertEqual(json.loads((self.migration.journal/'result.json').read_text())['status'], 'complete')
|
||||
|
||||
def test_real_environment_value_change_still_rolls_back(self):
|
||||
original_compose = self.migration.compose
|
||||
def recreate_with_changed_environment(*args):
|
||||
result = original_compose(*args)
|
||||
if args[0] == 'up':
|
||||
self.migration.items[0]['Config']['Env'] = ['EXAMPLE=changed']
|
||||
return result
|
||||
self.migration.compose = recreate_with_changed_environment
|
||||
with self.assertRaisesRegex(RuntimeError, '环境变量'):
|
||||
self.migration.execute()
|
||||
self.assertTrue((self.root/'state/synthetic.txt').exists())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
MI_FITNESS_ALLOWED_HOSTS: localhost,127.0.0.1,192.168.31.201
|
||||
MI_FITNESS_WEB_PASSWORD: ${MI_FITNESS_WEB_PASSWORD:?尚未初始化网页访问密码}
|
||||
volumes:
|
||||
- ./calendar:/calendar
|
||||
- ./web/calendar:/calendar
|
||||
ports: !override
|
||||
- '127.0.0.1:18322:8322'
|
||||
- '192.168.31.201:18322:8322'
|
||||
|
||||
@@ -11,7 +11,7 @@ import hashlib,sys
|
||||
from pathlib import Path
|
||||
assert hashlib.sha256(Path(sys.argv[1]).read_bytes()).hexdigest()==Path(sys.argv[2]).read_text().strip(), '现有覆盖配置已变化,停止避免覆盖'
|
||||
PY
|
||||
[ -d "$target/calendar" ] && [ ! -L "$target/calendar" ] || { echo '日历挂载目录缺失或异常,停止。'; exit 1; }
|
||||
[ -d "$target/web/calendar" ] && [ ! -L "$target/web/calendar" ] || { echo '日历挂载目录缺失或异常,停止。'; exit 1; }
|
||||
cd "$target"
|
||||
compose() { docker compose --project-name mi-fitness-health --env-file .env -f compose.yml -f compose.override.yml "$@"; }
|
||||
snapshot() { docker ps -aq --filter label=com.docker.compose.project=mi-fitness-health | sort | while read -r id; do docker inspect --format '{{index .Config.Labels "com.docker.compose.service"}} {{.Id}} {{.Image}}' "$id"; done | grep -v -E '^(backend|web) ' | sort; }
|
||||
|
||||
Reference in new issue
Block a user