feat: WMT client versioning, release management and force-update playbook

- api/wmt.py: add GET /api/wmt/client/version and GET /api/wmt/client/download endpoints; rewrite submit_update_request with dedup logic
- web/wmt.py: add releases, releases_upload, releases_delete, releases_build routes; build-from-folder excludes hidden/data/venv/pyc files
- web/main.py: admin per-device delete route; clear-device-logs route; pass devices list to admin template
- templates/wmt/releases.html: new release management page (current release info, upload form, build-from-folder card)
- templates/admin.html: replace nuclear clear-devices with clear-logs + per-device delete table
- templates/base.html: add Client Releases nav link in WMT sidebar section
- templates/ansible/execute.html: add Update WMT Code playbook card
- ansible/playbooks/update_wmt_code.yml: rsync WMT_project to clients excluding data/; backs up app.py; restarts wmt service
- ansible_service.py: register update_wmt_code description
- .gitignore: whitelist update_wmt_code.yml
This commit is contained in:
ske087
2026-05-13 16:36:17 +03:00
parent ccad5c1201
commit f1449285ba
10 changed files with 978 additions and 40 deletions
+115 -13
View File
@@ -2,8 +2,10 @@
WMT (Workstation Management Terminal) configuration API
Handles config distribution and device update requests from WMT clients.
"""
from flask import Blueprint, request, jsonify
from flask import Blueprint, request, jsonify, send_file
from datetime import datetime
from pathlib import Path
import json
from app.models import WMTGlobalConfig, Device, WMTUpdateRequest
from config.database_config import get_db
import logging
@@ -125,7 +127,11 @@ def get_device_config(mac_address):
@wmt_api_bp.route('/config/update_request', methods=['POST'])
def submit_update_request():
"""
WMT client sends current device info as an update request for admin approval.
WMT client sends current device info. Three outcomes:
1. Device not registered → create pending WMTUpdateRequest for admin approval.
2. Device registered, info UNCHANGED → update last_seen only; no request created.
3. Device registered, info CHANGED → create/refresh a pending WMTUpdateRequest.
Expected JSON:
{
@@ -133,6 +139,7 @@ def submit_update_request():
"device_name": "Masa-01",
"hostname": "rpi-masa01",
"device_ip": "192.168.1.100",
"card_presence": "enable", // optional applied directly, no approval
"client_config_mtime": "2026-04-22T09:30:00" // optional
}
"""
@@ -144,31 +151,126 @@ def submit_update_request():
if not mac:
return jsonify({'error': 'mac_address is required'}), 400
proposed_name = (data.get('device_name') or '').strip()
proposed_hostname = (data.get('hostname') or '').strip()
proposed_ip = (data.get('device_ip') or '').strip()
card_presence = data.get('card_presence')
def _eq(a, b):
"""Compare two values treating None and '' as equivalent."""
return (a or '') == (b or '')
try:
with get_db().get_session() as session:
device = session.query(Device).filter_by(mac_address=mac).first()
# Always update last_seen and card_presence (no approval needed)
if device:
device.last_seen = datetime.utcnow()
if card_presence in ('enable', 'disable'):
device.card_presence = card_presence
# --- Determine whether the proposed info differs from the server record ---
if device:
info_changed = not (
_eq(proposed_name, device.nume_masa) and
_eq(proposed_hostname, device.hostname) and
_eq(proposed_ip, device.device_ip)
)
else:
# Unknown device always needs admin attention
info_changed = True
if not info_changed:
# Heartbeat only nothing for admin to review
logger.debug(f'WMT heartbeat from {mac} info unchanged, last_seen updated')
return jsonify({'status': 'no_change', 'message': 'Device info matches server record'}), 200
# --- Info changed (or device unknown): avoid duplicate pending requests ---
existing = (
session.query(WMTUpdateRequest)
.filter_by(mac_address=mac, status='pending')
.order_by(WMTUpdateRequest.submitted_at.desc())
.first()
)
if existing and (
_eq(existing.proposed_device_name, proposed_name) and
_eq(existing.proposed_hostname, proposed_hostname) and
_eq(existing.proposed_device_ip, proposed_ip)
):
# Identical pending request already exists just refresh its timestamp
existing.submitted_at = datetime.utcnow()
logger.debug(f'WMT duplicate pending request from {mac} timestamp refreshed')
return jsonify({'status': 'pending', 'message': 'Update request already pending admin review'}), 200
# Create a new update request
req = WMTUpdateRequest(
mac_address=mac,
device_id=device.id if device else None,
proposed_device_name=data.get('device_name'),
proposed_hostname=data.get('hostname'),
proposed_device_ip=data.get('device_ip'),
proposed_device_name=proposed_name or None,
proposed_hostname=proposed_hostname or None,
proposed_device_ip=proposed_ip or None,
client_config_mtime=data.get('client_config_mtime'),
submitted_at=datetime.utcnow(),
status='pending',
)
session.add(req)
# Update device last_seen
if device:
device.last_seen = datetime.utcnow()
# card_presence is a device capability flag update directly (no approval needed)
if data.get('card_presence') in ('enable', 'disable'):
device.card_presence = data['card_presence']
logger.info(f'WMT update request received from {mac}')
reason = 'new device' if not device else 'info changed'
logger.info(f'WMT update request created for {mac} ({reason})')
return jsonify({'status': 'received', 'message': 'Update request queued for admin review'}), 201
except Exception as e:
logger.error(f'Error saving WMT update request from {mac}: {e}')
return jsonify({'error': str(e)}), 500
# ---------------------------------------------------------------------------
# WMT client auto-update endpoints
# ---------------------------------------------------------------------------
WMT_RELEASES_DIR = Path('data/wmt_releases')
@wmt_api_bp.route('/client/version', methods=['GET'])
def get_client_version():
"""
Returns the latest available WMT client version metadata.
Client calls this to decide if it needs to update.
Response: { "version": "3.0", "notes": "...", "uploaded_at": "...", "filename": "wmt_v3.0.zip" }
"""
meta_path = WMT_RELEASES_DIR / 'latest.json'
if not meta_path.exists():
return jsonify({'error': 'No release available'}), 404
try:
meta = json.loads(meta_path.read_text())
return jsonify(meta), 200
except Exception as e:
logger.error(f'Error reading WMT release metadata: {e}')
return jsonify({'error': str(e)}), 500
@wmt_api_bp.route('/client/download', methods=['GET'])
def download_client_release():
"""
Streams the latest WMT client release zip to the requesting device.
Client downloads this when its version is older than the server version.
"""
meta_path = WMT_RELEASES_DIR / 'latest.json'
if not meta_path.exists():
return jsonify({'error': 'No release available'}), 404
try:
meta = json.loads(meta_path.read_text())
zip_path = WMT_RELEASES_DIR / meta['filename']
if not zip_path.exists():
return jsonify({'error': f'Release file not found: {meta["filename"]}'}), 404
logger.info(f'WMT client downloading release {meta["version"]} from {request.remote_addr}')
return send_file(
str(zip_path.resolve()),
mimetype='application/zip',
as_attachment=True,
download_name=meta['filename'],
)
except Exception as e:
logger.error(f'Error serving WMT release: {e}')
return jsonify({'error': str(e)}), 500
+1
View File
@@ -737,6 +737,7 @@ class AnsibleService:
'system_health': 'Check system health and monitoring status',
'maintenance_mode': 'Put devices in maintenance mode',
'distribute_ssh_keys': 'Push server public key to all devices using password auth',
'update_wmt_code': 'Push WMT_project code to clients from controller (preserves data/)',
}
return descriptions.get(playbook_name, f'Execute {playbook_name} playbook')
+47 -2
View File
@@ -516,7 +516,18 @@ def admin():
logging.error(f'Admin inventory stats error: {e}')
stats['inventory_hosts'] = '?'
stats['inventory_groups_yaml'] = '?'
return render_template('admin.html', stats=stats)
# Pass registered devices for per-device delete UI
devices = []
try:
with get_db().get_session() as session:
devices = [
{'id': d.id, 'hostname': d.hostname, 'nume_masa': d.nume_masa,
'mac_address': d.mac_address, 'device_ip': d.device_ip}
for d in session.query(Device).order_by(Device.nume_masa).all()
]
except Exception as e:
logging.error(f'Admin device list error: {e}')
return render_template('admin.html', stats=stats, devices=devices)
@main_bp.route('/admin/clear/logs', methods=['POST'])
@@ -532,9 +543,43 @@ def admin_clear_logs():
return jsonify({'success': False, 'error': str(e)}), 500
@main_bp.route('/admin/clear/device-logs', methods=['POST'])
def admin_clear_device_logs():
"""Delete all log entries from the database (devices stay intact)."""
try:
with get_db().get_session() as session:
count = session.query(LogEntry).delete()
session.commit()
return jsonify({'success': True, 'deleted': count})
except Exception as e:
logging.error(f'Admin clear device logs error: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
@main_bp.route('/admin/delete/device/<int:device_id>', methods=['POST'])
def admin_delete_device(device_id):
"""Delete a single registered device and its log entries."""
try:
with get_db().get_session() as session:
device = session.query(Device).filter_by(id=device_id).first()
if not device:
return jsonify({'success': False, 'error': 'Device not found'}), 404
name = device.nume_masa or device.hostname
session.execute(text('DELETE FROM device_inventory_groups WHERE device_id = :id'), {'id': device_id})
session.query(LogEntry).filter_by(device_id=device_id).delete()
session.query(WMTUpdateRequest).filter_by(device_id=device_id).delete()
session.delete(device)
session.commit()
logging.info(f'Admin deleted device {device_id} ({name})')
return jsonify({'success': True, 'name': name})
except Exception as e:
logging.error(f'Admin delete device {device_id} error: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
@main_bp.route('/admin/clear/devices', methods=['POST'])
def admin_clear_devices():
"""Delete all devices (and their log entries) from the database."""
"""Delete ALL devices (and their log entries) from the database."""
try:
with get_db().get_session() as session:
session.execute(text('DELETE FROM device_inventory_groups'))
+199 -2
View File
@@ -3,8 +3,12 @@ WMT management web routes global settings, device registry, update requests.
"""
import csv
import io
from flask import Blueprint, render_template, request, redirect, url_for, flash, Response
from datetime import datetime
import json
import re
import zipfile
from pathlib import Path
from flask import Blueprint, render_template, request, redirect, url_for, flash, Response, jsonify
from datetime import datetime, timezone
from app.models import WMTGlobalConfig, Device, WMTUpdateRequest
from config.database_config import get_db
import logging
@@ -363,3 +367,196 @@ def device_delete(device_id):
logger.error(f'WMT device_delete error: {e}')
flash(f'Error: {e}', 'error')
return redirect(url_for('wmt_web.devices'))
# ---------------------------------------------------------------------------
# WMT Client Release Management
# ---------------------------------------------------------------------------
WMT_RELEASES_DIR = Path('data/wmt_releases')
def _read_release_meta():
meta_path = WMT_RELEASES_DIR / 'latest.json'
if meta_path.exists():
try:
return json.loads(meta_path.read_text())
except Exception:
pass
return None
@wmt_web_bp.route('/releases')
def releases():
"""WMT client release management page."""
meta = _read_release_meta()
zip_size = None
if meta:
zip_path = WMT_RELEASES_DIR / meta.get('filename', '')
if zip_path.exists():
zip_size = zip_path.stat().st_size
return render_template('wmt/releases.html', meta=meta, zip_size=zip_size)
@wmt_web_bp.route('/releases/upload', methods=['POST'])
def releases_upload():
"""Upload a new WMT client release zip and set it as latest."""
f = request.files.get('release_zip')
version_str = request.form.get('version', '').strip()
notes = request.form.get('notes', '').strip()
if not f or not f.filename.endswith('.zip'):
flash('Please upload a .zip file.', 'error')
return redirect(url_for('wmt_web.releases'))
if not version_str:
flash('Version is required.', 'error')
return redirect(url_for('wmt_web.releases'))
# Validate version format (digits and dots)
if not re.match(r'^\d+(\.\d+)*$', version_str):
flash('Version must be in numeric format (e.g. 3.0 or 3.1.2).', 'error')
return redirect(url_for('wmt_web.releases'))
try:
WMT_RELEASES_DIR.mkdir(parents=True, exist_ok=True)
filename = f'wmt_v{version_str}.zip'
zip_path = WMT_RELEASES_DIR / filename
# Validate the zip contains app.py
data = f.read()
try:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
names = zf.namelist()
if 'app.py' not in names:
flash('Invalid release: zip must contain app.py at the root.', 'error')
return redirect(url_for('wmt_web.releases'))
except zipfile.BadZipFile:
flash('Uploaded file is not a valid zip archive.', 'error')
return redirect(url_for('wmt_web.releases'))
zip_path.write_bytes(data)
meta = {
'version': version_str,
'notes': notes,
'uploaded_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S'),
'filename': filename,
}
(WMT_RELEASES_DIR / 'latest.json').write_text(json.dumps(meta, indent=2))
logger.info(f'New WMT release uploaded: v{version_str} ({filename})')
flash(f'Release v{version_str} uploaded and set as latest.', 'success')
except Exception as e:
logger.error(f'WMT release upload error: {e}')
flash(f'Upload error: {e}', 'error')
return redirect(url_for('wmt_web.releases'))
@wmt_web_bp.route('/releases/delete', methods=['POST'])
def releases_delete():
"""Remove the current release zip and metadata."""
try:
meta = _read_release_meta()
if meta:
zip_path = WMT_RELEASES_DIR / meta.get('filename', '')
if zip_path.exists():
zip_path.unlink()
meta_path = WMT_RELEASES_DIR / 'latest.json'
if meta_path.exists():
meta_path.unlink()
flash('Release deleted.', 'success')
except Exception as e:
logger.error(f'WMT release delete error: {e}')
flash(f'Error: {e}', 'error')
return redirect(url_for('wmt_web.releases'))
# Patterns excluded when building a release from a local folder
_BUILD_EXCLUDE_DIRS = {'.git', '__pycache__', 'data', 'venv', '.venv', 'node_modules'}
_BUILD_EXCLUDE_EXTS = {'.pyc', '.pyo', '.bak', '.log', '.swp'}
def _should_exclude(rel_parts):
"""Return True if this path should be left out of the release zip."""
# Any hidden segment (starts with '.')
for part in rel_parts:
if part.startswith('.'):
return True
# Top-level or nested dir exclusion
if rel_parts[0] in _BUILD_EXCLUDE_DIRS:
return True
# Extension exclusion
if Path(rel_parts[-1]).suffix.lower() in _BUILD_EXCLUDE_EXTS:
return True
return False
@wmt_web_bp.route('/releases/build', methods=['POST'])
def releases_build():
"""
Build a release zip directly from a folder on this server,
excluding hidden files, __pycache__, data/, venv/, etc.
"""
folder_path = request.form.get('folder_path', '/home/pi/Desktop/WMT').strip()
version_str = request.form.get('version', '').strip()
notes = request.form.get('notes', '').strip()
if not version_str:
flash('Version is required.', 'error')
return redirect(url_for('wmt_web.releases'))
if not re.match(r'^\d+(\.\d+)*$', version_str):
flash('Version must be in numeric format (e.g. 3.0).', 'error')
return redirect(url_for('wmt_web.releases'))
src = Path(folder_path)
if not src.is_dir():
flash(f'Folder not found: {folder_path}', 'error')
return redirect(url_for('wmt_web.releases'))
try:
buf = io.BytesIO()
file_count = 0
has_app_py = False
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
for abs_path in sorted(src.rglob('*')):
if not abs_path.is_file():
continue
rel = abs_path.relative_to(src)
parts = rel.parts
if _should_exclude(parts):
continue
arcname = str(rel)
zf.write(abs_path, arcname)
file_count += 1
if arcname == 'app.py':
has_app_py = True
if not has_app_py:
flash(f'No app.py found in {folder_path} — cannot create release.', 'error')
return redirect(url_for('wmt_web.releases'))
WMT_RELEASES_DIR.mkdir(parents=True, exist_ok=True)
filename = f'wmt_v{version_str}.zip'
zip_path = WMT_RELEASES_DIR / filename
zip_path.write_bytes(buf.getvalue())
meta = {
'version': version_str,
'notes': notes,
'uploaded_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S'),
'filename': filename,
}
(WMT_RELEASES_DIR / 'latest.json').write_text(json.dumps(meta, indent=2))
size_kb = len(buf.getvalue()) // 1024
logger.info(f'WMT release built from {folder_path}: v{version_str}, {file_count} files, {size_kb} KB')
flash(f'Release v{version_str} built from {folder_path} ({file_count} files, {size_kb} KB).', 'success')
except Exception as e:
logger.error(f'WMT release build error: {e}')
flash(f'Build error: {e}', 'error')
return redirect(url_for('wmt_web.releases'))