aboutsummaryrefslogtreecommitdiff
path: root/scripts/newos4-maintain.py
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/newos4-maintain.py')
-rw-r--r--scripts/newos4-maintain.py118
1 files changed, 118 insertions, 0 deletions
diff --git a/scripts/newos4-maintain.py b/scripts/newos4-maintain.py
new file mode 100644
index 0000000..6321a3f
--- /dev/null
+++ b/scripts/newos4-maintain.py
@@ -0,0 +1,118 @@
+#!/usr/bin/env python3
+"""newOS4 maintenance: drift, bundles, syntax, perms, data hygiene, live health.
+Safe fixes only with --fix. Never touches nginx, services, sudo, or user content."""
+import glob, json, os, re, subprocess, sys
+from datetime import datetime, timezone
+
+ROOT = '/var/www/webx11.duckdns.org/spaces/newos4'
+WEB = 'https://webx11.duckdns.org/spaces/newos4'
+FIX = '--fix' in sys.argv
+DEEP = '--deep' in sys.argv
+issues = []
+def add(l, m): issues.append((l, m))
+def run(cmd, **kw): return subprocess.run(cmd, capture_output=True, text=True, **kw)
+def sh(cmd): return run(cmd, shell=True).stdout.strip()
+
+def check_versions():
+ html = open(os.path.join(ROOT, 'main.html'), encoding='utf-8').read()
+ loader = open(os.path.join(ROOT, 'modules/core/module-loader.js'), encoding='utf-8').read()
+ m = re.search(r"const LATEST_VER\s*=\s*'([^']+)'", loader)
+ ver = m.group(1) if m else '?'
+ n = html.count(ver)
+ add('PASS' if n == 3 else 'FAIL', f'version {ver} x{n} in main.html (expect 3)')
+
+def check_bundles():
+ paths = ('modules/core/core-bundle.js', 'modules/ext/critical-ext-bundle.js')
+ before = {p: sh(f'md5sum {p}') for p in paths}
+ if not FIX:
+ add('PASS', 'bundle check skipped (use --fix)'); return
+ r = run([sys.executable, 'scripts/rebuild-bundles.py'], cwd=ROOT)
+ after = {p: sh(f'md5sum {p}') for p in paths}
+ changed = [p for p in paths if before[p] != after[p]]
+ add('FAIL' if r.returncode else ('WARN' if changed else 'PASS'),
+ 'bundles ' + ('FAILED' if r.returncode else ('rebuilt: ' + ', '.join(changed) if changed else 'clean')))
+
+def check_syntax():
+ bad = []
+ for p in glob.glob(os.path.join(ROOT, 'modules/core/*.js')) + glob.glob(os.path.join(ROOT, 'modules/ext/*.js')):
+ if os.path.basename(p) in ('core-bundle.js', 'critical-ext-bundle.js'): continue
+ if run(['node', '--check', p]).returncode: bad.append(os.path.basename(p))
+ add('PASS' if not bad else 'FAIL', 'syntax ' + ('OK' if not bad else 'fail: ' + ', '.join(bad)))
+
+def check_perms():
+ targets = glob.glob(os.path.join(ROOT, 'course/*.json')) + glob.glob(os.path.join(ROOT, 'course/chapters/*.json')) \
+ + [os.path.join(ROOT, 'modules/ext/course.js'), os.path.join(ROOT, 'course.html'), os.path.join(ROOT, 'api/course/event.php')]
+ bad = []
+ for p in targets:
+ if not os.path.exists(p): continue
+ if (os.stat(p).st_mode & 0o777) != 0o644 or sh(f'stat -c %G {p}') != 'www-data':
+ if FIX:
+ os.chmod(p, 0o644); sh(f'chgrp www-data {p}')
+ else: bad.append(os.path.basename(p))
+ add('PASS' if not bad else ('WARN' if FIX else 'FAIL'), 'perms ' + ('clean' if not bad else ('fixed' if FIX else 'bad: ' + ', '.join(bad[:5]))))
+
+def check_index():
+ idxp = os.path.join(ROOT, 'modules-index.json')
+ atlas = json.load(open(os.path.join(ROOT, 'course/atlas.json')))
+ idx = json.load(open(idxp))
+ cur = len(set(c for m in idx.get('modules', {}).values() for c in m.get('commands', [])))
+ if cur == atlas['coverage']['commands']:
+ add('PASS', f'modules-index {cur} commands'); return
+ add('WARN', f'modules-index stale {cur} vs {atlas["coverage"]["commands"]}')
+ if not FIX: return
+ for name, m in atlas['modules'].items():
+ e = idx['modules'].get(name, {'name': name, 'file': m['file'], 'type': 'core' if m['tier'] == 'core-bundle' else 'ext',
+ 'class': '', 'auto_load': m['tier'] != 'ext-manual', 'description': ''})
+ e.update({'commands': m['commands'], 'file': m['file'], 'tier': m['tier']})
+ idx['modules'][name] = e
+ idx['version'] = datetime.now(timezone.utc).strftime('%Y-%m-%d') + '-atlas'
+ tmp = idxp + '.tmp'
+ with open(tmp, 'w', encoding='utf-8') as f: json.dump(idx, f, ensure_ascii=False, indent=1)
+ os.replace(tmp, idxp); os.chmod(idxp, 0o644); sh(f'chgrp www-data {idxp}')
+ add('PASS', 'modules-index regenerated')
+
+def check_data():
+ err = os.path.join(ROOT, 'data/debug/errors.json')
+ if os.path.exists(err) and os.path.getsize(err) > 5 * 1024 * 1024:
+ if FIX:
+ arc = os.path.join(ROOT, 'data/debug/archive'); os.makedirs(arc, exist_ok=True)
+ os.replace(err, os.path.join(arc, 'errors-' + datetime.now().strftime('%Y%m') + '.json'))
+ open(err, 'w').write('[]'); os.chmod(err, 0o664); sh(f'chgrp www-data {err}')
+ add('PASS', 'errors.json rotated')
+ else: add('WARN', 'errors.json >5MB')
+ else: add('PASS', 'debug data OK')
+
+def check_live():
+ for url in (f'{WEB}/main.html', f'{WEB}/course.html', f'{WEB}/course/course.json', f'{WEB}/modules/core/core-bundle.js'):
+ code = sh(f"curl -s -o /dev/null -w '%{{http_code}}' '{url}'")
+ add('PASS' if code == '200' else 'FAIL', f'HTTP {code} {url.replace(WEB, "")}')
+
+def check_course():
+ r = run([sys.executable, 'scripts/course-validate.py'], cwd=ROOT)
+ add('PASS' if r.returncode == 0 else 'FAIL', 'course ' + ('valid' if r.returncode == 0 else 'INVALID: ' + r.stdout[-160:]))
+
+def check_links():
+ if not DEEP: return
+ broken = []
+ for page in ('main.html', 'course.html', 'help.html', 'modules.html', 'guide.html'):
+ p = os.path.join(ROOT, page)
+ if not os.path.exists(p): continue
+ for href in re.findall(r'(?:href|src)="([^"#]+)"', open(p, encoding='utf-8').read()):
+ if any(x in href for x in ("'", '+', '{', '}')): continue
+ if href.startswith(('http', '//', 'mailto:', 'javascript:', 'data:', '/api/')): continue
+ target = os.path.normpath(os.path.join(ROOT, href.split('?')[0]))
+ if not os.path.exists(target): broken.append(f'{page}:{href}')
+ add('PASS' if not broken else 'WARN', 'links ' + ('OK' if not broken else f'{len(broken)} broken (first: {broken[0]})'))
+
+def main():
+ os.chdir(ROOT)
+ check_versions(); check_bundles(); check_syntax(); check_perms(); check_index(); check_data(); check_live(); check_course(); check_links()
+ fails = [m for l, m in issues if l == 'FAIL']
+ print('newOS4 maintenance — ' + datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%MZ') + (' [fix]' if FIX else '') + (' [deep]' if DEEP else ''))
+ for l, m in issues: print(f'{l}: {m}')
+ print('maintenance result: ' + ('FAIL' if fails else 'OK'))
+ print('needs OK: ' + ('none' if not fails else '; '.join(fails)[:400]))
+ return 1 if fails else 0
+
+if __name__ == '__main__':
+ sys.exit(main())