aboutsummaryrefslogtreecommitdiff
path: root/scripts/newos4-maintain.py
blob: 6321a3f511eacdb4dc708c71dd2b0c8f35fb0fe1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
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())