feat(deploy): reliability API routes, atomic releases, version single-source

This commit is contained in:
2026-08-22 15:20:39 -06:00
parent aba08e36c7
commit 1bb15de258
10 changed files with 586 additions and 40 deletions
@@ -1,8 +1,9 @@
"""UCI profile snapshots under /mmc/mk8/profiles/<name>/{pineapd,wireless,network}"""
import os, time
import os, re, time
PROFILES_DIR = '/mmc/mk8/profiles'
CONFIGS = ('pineapd', 'wireless', 'network')
NAME_RE = re.compile(r'^[A-Za-z0-9._-]{1,64}$')
def run_cmd(args, timeout=20, input_data=None):
@@ -12,6 +13,12 @@ def run_cmd(args, timeout=20, input_data=None):
def _path(name):
"""Resolve a profile name to its directory. HTTP-supplied names are never
trusted: reject anything but [A-Za-z0-9._-]{1,64} and explicitly refuse
'.'/'..' so traversal can never escape PROFILES_DIR."""
if not isinstance(name, str) or not NAME_RE.match(name) \
or name in ('.', '..'):
raise ValueError('invalid profile name')
return os.path.join(PROFILES_DIR, name)
@@ -38,8 +45,14 @@ def auto_name(op):
def list_profiles():
try:
return sorted(d for d in os.listdir(PROFILES_DIR)
if os.path.isdir(_path(d)))
out = []
for d in os.listdir(PROFILES_DIR):
try:
if os.path.isdir(_path(d)):
out.append(d)
except ValueError:
continue
return sorted(out)
except OSError:
return []
@@ -4863,6 +4863,60 @@ def h_rfplan_post(ctx):
return (200, result) if ok else (502, result)
def h_profiles_get(ctx):
import mk8_profiles
return 200, {'profiles': mk8_profiles.list_profiles()}
def h_profile_save(ctx):
import mk8_events
import mk8_profiles
body = ctx.body or {}
name = (body.get('name') or '').strip()
if not name:
return 400, {'error': 'profile name is required'}
try:
ok = bool(mk8_profiles.snapshot(name))
except ValueError as exc:
return 400, {'error': str(exc)}
except OSError:
ok = False
try:
mk8_events.log_event('profile_save', sev='info' if ok else 'warn',
msg='profile %s %s'
% (name, 'saved' if ok else 'save failed'),
meta={'name': name, 'ok': ok})
except Exception:
pass
return (200, {'ok': True}) if ok else (502, {'ok': False})
def h_profile_restore(ctx):
import mk8_events
import mk8_gate
import mk8_profiles
body = ctx.body or {}
name = (body.get('name') or '').strip()
if not name:
return 400, {'error': 'profile name is required'}
mk8_gate.enter('restore_profile')
try:
result = mk8_profiles.restore(name)
except ValueError as exc:
return 400, {'error': str(exc)}
device_run(['wifi', 'reload'], timeout=45)
ok = bool(result.get('ok'))
try:
mk8_events.log_event('profile_restore',
sev='info' if ok else 'warn',
msg='profile %s %s' % (name, 'restored' if ok
else 'restore failed'),
meta=result)
except Exception:
pass
return (200, result) if ok else (502, result)
# --------------------------------------------------------------------------
# Startup environment check: run once at service startup (and via
# ``server.py --env-check`` on the payload screen) to make the device match
@@ -7247,6 +7301,9 @@ ROUTER.add('GET', r'/api/mode', h_mode_get)
ROUTER.add('POST', r'/api/mode/release', h_mode_release)
ROUTER.add('GET', r'/api/rfplan', h_rfplan_get)
ROUTER.add('POST', r'/api/rfplan/role', h_rfplan_post)
ROUTER.add('GET', r'/api/reliability/profiles', h_profiles_get)
ROUTER.add('POST', r'/api/reliability/profile', h_profile_save)
ROUTER.add('POST', r'/api/reliability/restore', h_profile_restore)
def serve():
@@ -269,7 +269,7 @@
<script src="js/xterm-addon-fit.min.js"></script>
<script src="js/terminal.js?v=20260820-4"></script>
<script src="js/pager.js?v=20260820-4"></script>
<script src="js/views.js?v=20260822-1"></script>
<script src="js/views.js?v=20260822-2"></script>
<script src="js/app.js?v=20260822-1"></script>
</body>
</html>
@@ -3920,6 +3920,43 @@ views.settings = (root) => {
overlay.appendChild(restoreBtn);
loadOverlay();
const profiles = settingsCard(box, 'Reliability Profiles',
'Snapshots of the PineAP, wireless, and network UCI config. Save one before risky changes; restoring rolls the config back and reloads the radios.');
const profBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' });
profiles.appendChild(profBody);
function loadProfiles() {
PagerAPI.get('/api/reliability/profiles').then((r) => {
const names = (r.data || {}).profiles || [];
profBody.innerHTML = '';
if (!names.length) {
profBody.appendChild(h('div', { class: 'empty', text: 'No saved profiles yet.' }));
return;
}
profBody.appendChild(table([
{ label: 'Profile', key: 'name' },
{ label: '', render: (row) => {
const doRestore = btn('Restore', () => runAction(doRestore,
() => PagerAPI.post('/api/reliability/restore', { name: row.name })
.then(() => App.toast('Profile restored; radios reloaded')), 'Restoring…'), 'ghost');
return doRestore;
} }
], names.map((name) => ({ name }))));
}).catch(() => { profBody.textContent = 'Unable to load profiles.'; });
}
const profName = h('input', { placeholder: 'e.g. pre-evilwpa', maxlength: '64',
autocomplete: 'off' });
const profSave = btn('Save Profile', () => {
const name = profName.value.trim();
if (!name) { App.toast('Profile name is required', 'error'); return; }
return runAction(profSave, () => PagerAPI.post('/api/reliability/profile', { name })
.then(() => { profName.value = ''; App.toast('Config profile saved'); loadProfiles(); }), 'Saving…');
});
profiles.appendChild(h('div', { class: 'settings-form-grid' },
h('label', {}, 'Profile Name', profName)));
profiles.appendChild(h('div', { class: 'settings-actions' }, profSave,
btn('Refresh', () => { loadProfiles(); }, 'ghost')));
loadProfiles();
settingsCard(box, 'Button Script', 'The Mark VII button script has no safe Pager equivalent. Pager buttons remain managed by the native input and payload-launcher system.');
const resources = settingsCard(box, 'Resources');