Development
This commit is contained in:
parent
113328c566
commit
01a636e842
16 changed files with 388 additions and 502 deletions
|
|
@ -14,7 +14,7 @@ DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done'
|
|||
DASHBOARD_LAST_RUN = f'{CONFIGS_DIR}/.dashboard-last-run'
|
||||
DASHBOARD_LOCK = f'{CONFIGS_DIR}/.dashboard-lock'
|
||||
DASHBOARD_PENDING = f'{CONFIGS_DIR}/.dashboard-pending'
|
||||
SNAPSHOTS_DIR = f'{CONFIGS_DIR}/.snapshots'
|
||||
DASHBOARD_DB = f'{CONFIGS_DIR}/.dashboard-snapshots'
|
||||
HEALTH_FILE = f'{CONFIGS_DIR}/.health'
|
||||
PRODUCT_NAME = os.environ.get('PRODUCT_NAME', 'routlin')
|
||||
DASHB_TIMER_NAME = f'{PRODUCT_NAME}-dashboard-queue'
|
||||
|
|
@ -339,153 +339,225 @@ def queued_msg(cmd=None, description='', action_label='Configuration saved'):
|
|||
|
||||
# Snapshot system ===================================================
|
||||
|
||||
def _pending_uuid_set():
|
||||
return {item[0] for item in _read_dashboard_pending()}
|
||||
import re as _re
|
||||
import sqlite3 as _sqlite3
|
||||
|
||||
|
||||
def _find_snapshot_dependencies(path, key):
|
||||
"""Return UUIDs of still-pending snapshots that modified the same path+key."""
|
||||
try:
|
||||
pending = _pending_uuid_set()
|
||||
deps = []
|
||||
for fname in sorted(os.listdir(SNAPSHOTS_DIR)):
|
||||
if not fname.endswith('.json'):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(SNAPSHOTS_DIR, fname)) as f:
|
||||
snap = json.load(f)
|
||||
if (snap.get('path') == path
|
||||
and snap.get('key') == str(key)
|
||||
and snap.get('uuid') in pending):
|
||||
deps.append(snap['uuid'])
|
||||
except Exception:
|
||||
pass
|
||||
return deps
|
||||
except Exception:
|
||||
return []
|
||||
def _db():
|
||||
conn = _sqlite3.connect(DASHBOARD_DB)
|
||||
conn.row_factory = _sqlite3.Row
|
||||
conn.execute('PRAGMA journal_mode=WAL')
|
||||
conn.executescript('''
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
uuid TEXT PRIMARY KEY,
|
||||
ts INTEGER NOT NULL,
|
||||
cmd TEXT,
|
||||
user TEXT,
|
||||
parent_path TEXT NOT NULL,
|
||||
item_key TEXT,
|
||||
item_value TEXT,
|
||||
reverts_group TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS changes (
|
||||
group_id TEXT NOT NULL REFERENCES groups(uuid),
|
||||
field TEXT NOT NULL,
|
||||
before TEXT,
|
||||
after TEXT,
|
||||
value_type TEXT NOT NULL,
|
||||
PRIMARY KEY (group_id, field)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_changes_group ON changes(group_id);
|
||||
''')
|
||||
return conn
|
||||
|
||||
|
||||
def _items_match(item, ref):
|
||||
"""Return True if item and ref refer to the same entity by a common identifier field."""
|
||||
if not isinstance(item, dict) or not isinstance(ref, dict):
|
||||
return item == ref
|
||||
for field in ('ip', 'name', 'mac_address', 'host', 'id', 'address'):
|
||||
if field in ref and field in item:
|
||||
return item[field] == ref[field]
|
||||
return item == ref
|
||||
def _py_value_type(val):
|
||||
if val is None: return 'null'
|
||||
if isinstance(val, bool): return 'bool'
|
||||
if isinstance(val, int): return 'int'
|
||||
if isinstance(val, float): return 'float'
|
||||
if isinstance(val, (dict, list)): return 'json'
|
||||
return 'str'
|
||||
|
||||
|
||||
def revert_snapshot_to_config(entry_uuid):
|
||||
"""Apply the inverse of a snapshot to config.json and queue a new pending change.
|
||||
def _serialize_value(val):
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, bool):
|
||||
return 'true' if val else 'false'
|
||||
if isinstance(val, (dict, list)):
|
||||
return json.dumps(val, separators=(',', ':'))
|
||||
return str(val)
|
||||
|
||||
Returns (flash_message, success_bool).
|
||||
|
||||
def _deserialize_value(text, value_type):
|
||||
if text is None:
|
||||
return None
|
||||
if value_type == 'int': return int(text)
|
||||
if value_type == 'float': return float(text)
|
||||
if value_type == 'bool': return text == 'true'
|
||||
if value_type in ('json', 'null'): return json.loads(text)
|
||||
return text
|
||||
|
||||
|
||||
def diff_fields(before_dict, after_dict):
|
||||
"""Return list of (field, before_text, after_text, value_type) for changed fields."""
|
||||
bd = before_dict or {}
|
||||
ad = after_dict or {}
|
||||
result = []
|
||||
for key in sorted(set(bd) | set(ad)):
|
||||
bval = bd.get(key)
|
||||
aval = ad.get(key)
|
||||
if bval == aval:
|
||||
continue
|
||||
ref = aval if aval is not None else bval
|
||||
result.append((
|
||||
key,
|
||||
_serialize_value(bval),
|
||||
_serialize_value(aval),
|
||||
_py_value_type(ref),
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
_PATH_SEG = _re.compile(r'([^\.\[]+)(?:\[([^\]=]+)=([^\]]+)\])?')
|
||||
|
||||
|
||||
def _parse_path(path):
|
||||
"""Parse 'vlans[name=trusted].field' into [(field, sel_key, sel_val), ...]."""
|
||||
return [(m.group(1), m.group(2), m.group(3)) for m in _PATH_SEG.finditer(path)]
|
||||
|
||||
|
||||
def _nav_get(cfg, path):
|
||||
"""Navigate config to the value at path."""
|
||||
for field, sel_key, sel_val in _parse_path(path):
|
||||
cfg = cfg[field]
|
||||
if sel_key:
|
||||
cfg = next(x for x in cfg if str(x.get(sel_key, '')) == str(sel_val))
|
||||
return cfg
|
||||
|
||||
|
||||
def _nav_parent(cfg, path):
|
||||
"""Return (parent_obj, final_key) for setting/deleting the last path segment."""
|
||||
segs = _parse_path(path)
|
||||
for field, sel_key, sel_val in segs[:-1]:
|
||||
cfg = cfg[field]
|
||||
if sel_key:
|
||||
cfg = next(x for x in cfg if str(x.get(sel_key, '')) == str(sel_val))
|
||||
return cfg, segs[-1][0]
|
||||
|
||||
|
||||
def record_group(cfg, parent_path, item_key, item_value, changes, cmd,
|
||||
reverts_group=None, queue=True):
|
||||
"""Insert a group + changes into sqlite, save config, and queue the command.
|
||||
|
||||
Returns a flash message string.
|
||||
"""
|
||||
snap = load_snapshot_for_uuid(entry_uuid)
|
||||
if not snap:
|
||||
return f'Snapshot not found for {entry_uuid[:8]}.', False
|
||||
|
||||
path = snap['path']
|
||||
key = snap['key']
|
||||
before = snap['before'] # original state to restore
|
||||
after = snap['after'] # applied state to undo
|
||||
operation = snap['operation']
|
||||
|
||||
if operation == 'revert':
|
||||
return 'This change is already a revert; cannot revert again.', False
|
||||
|
||||
core = load_config()
|
||||
|
||||
if key == 'global':
|
||||
if before is None:
|
||||
core.pop(path, None)
|
||||
else:
|
||||
core[path] = before
|
||||
else:
|
||||
items = core.setdefault(path, [])
|
||||
if operation == 'add':
|
||||
core[path] = [x for x in items if not _items_match(x, after)]
|
||||
elif operation == 'delete':
|
||||
if before:
|
||||
core[path].append(before)
|
||||
else:
|
||||
if before and after:
|
||||
for i, item in enumerate(items):
|
||||
if _items_match(item, after):
|
||||
items[i] = before
|
||||
break
|
||||
|
||||
msg = save_config_with_snapshot(
|
||||
core, path=path, key=key, operation='revert',
|
||||
before=after, after=before,
|
||||
description=f"Reverted: {snap.get('description', '')}",
|
||||
cmd=snap.get('cmd', 'core apply'),
|
||||
reverts=entry_uuid,
|
||||
)
|
||||
return msg or 'Reverted.', True
|
||||
|
||||
|
||||
def load_snapshot_for_uuid(entry_uuid):
|
||||
"""Return the snapshot dict for the given UUID, or None if not found."""
|
||||
try:
|
||||
for fname in os.listdir(SNAPSHOTS_DIR):
|
||||
if fname.endswith(f'-{entry_uuid}.json'):
|
||||
with open(os.path.join(SNAPSHOTS_DIR, fname)) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def save_config_with_snapshot(new_config, path, key, operation, before, after,
|
||||
description='', cmd='core apply', queue=True, reverts=None):
|
||||
"""
|
||||
Write a .snapshots/{ts}-{uuid}.json file, save new_config to disk, and
|
||||
optionally create a pending queue entry. Returns a flash message string.
|
||||
|
||||
queue=False: skips queueing and records the change directly in
|
||||
.dashboard-done so it appears in Change History without a pending step.
|
||||
"""
|
||||
entry_uuid = str(uuid.uuid4())
|
||||
group_uuid = str(uuid.uuid4())
|
||||
entry_ts = int(datetime.now().timestamp())
|
||||
current_user = session.get('email_address', 'unknown')
|
||||
|
||||
depends_on = _find_snapshot_dependencies(path, key)
|
||||
conn = _db()
|
||||
try:
|
||||
conn.execute(
|
||||
'INSERT INTO groups '
|
||||
'(uuid,ts,cmd,user,parent_path,item_key,item_value,reverts_group) '
|
||||
'VALUES (?,?,?,?,?,?,?,?)',
|
||||
(group_uuid, entry_ts, cmd, current_user,
|
||||
parent_path, item_key, item_value, reverts_group)
|
||||
)
|
||||
for field, before, after, value_type in changes:
|
||||
conn.execute(
|
||||
'INSERT INTO changes (group_id,field,before,after,value_type) '
|
||||
'VALUES (?,?,?,?,?)',
|
||||
(group_uuid, field, before, after, value_type)
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
os.makedirs(SNAPSHOTS_DIR, exist_ok=True)
|
||||
snapshot = {
|
||||
'uuid': entry_uuid,
|
||||
'ts': entry_ts,
|
||||
'cmd': cmd,
|
||||
'user': current_user,
|
||||
'operation': operation,
|
||||
'description': description,
|
||||
'path': path,
|
||||
'key': str(key),
|
||||
'before': before,
|
||||
'after': after,
|
||||
'depends_on': depends_on,
|
||||
'reverts': reverts,
|
||||
}
|
||||
with open(os.path.join(SNAPSHOTS_DIR, f'{entry_ts}-{entry_uuid}.json'), 'w') as f:
|
||||
json.dump(snapshot, f, indent=2)
|
||||
|
||||
save_config(new_config)
|
||||
save_config(cfg)
|
||||
|
||||
if not queue:
|
||||
with open(DASHBOARD_DONE, 'a') as f:
|
||||
f.write(f'{entry_uuid} {entry_ts}\n')
|
||||
f.write(f'{group_uuid} {entry_ts}\n')
|
||||
return 'Saved.'
|
||||
|
||||
if _apply_changes_immediately():
|
||||
with open(DASHBOARD_QUEUE, 'a') as f:
|
||||
f.write(f'{entry_uuid} {entry_ts} [{cmd}] ({current_user})\n')
|
||||
f.write(f'{group_uuid} {entry_ts} [{cmd}] ({current_user})\n')
|
||||
_trim_if_needed()
|
||||
else:
|
||||
_queue_pending_presigned(cmd, entry_uuid, entry_ts)
|
||||
_queue_pending_presigned(cmd, group_uuid, entry_ts)
|
||||
|
||||
return _build_timing_msg(entry_ts)
|
||||
|
||||
|
||||
def load_all_groups():
|
||||
"""Return list of (group_dict, [change_dicts]) sorted newest first."""
|
||||
conn = _db()
|
||||
try:
|
||||
gs = conn.execute('SELECT * FROM groups ORDER BY ts DESC').fetchall()
|
||||
result = []
|
||||
for g in gs:
|
||||
cs = conn.execute(
|
||||
'SELECT * FROM changes WHERE group_id=? ORDER BY field', (g['uuid'],)
|
||||
).fetchall()
|
||||
result.append((dict(g), [dict(c) for c in cs]))
|
||||
return result
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def revert_group(group_uuid):
|
||||
"""Revert a change group. Returns (flash_message, success_bool)."""
|
||||
conn = _db()
|
||||
try:
|
||||
g = conn.execute('SELECT * FROM groups WHERE uuid=?', (group_uuid,)).fetchone()
|
||||
if not g:
|
||||
return f'Snapshot not found for {group_uuid[:8]}.', False
|
||||
g = dict(g)
|
||||
changes = [dict(c) for c in conn.execute(
|
||||
'SELECT * FROM changes WHERE group_id=?', (group_uuid,)
|
||||
).fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if g['reverts_group']:
|
||||
return 'Cannot revert a revert.', False
|
||||
|
||||
cfg = load_config()
|
||||
parent_path = g['parent_path']
|
||||
item_key = g['item_key']
|
||||
item_value = g['item_value']
|
||||
|
||||
all_before_null = all(c['before'] is None for c in changes)
|
||||
all_after_null = all(c['after'] is None for c in changes)
|
||||
|
||||
if all_before_null:
|
||||
parent_obj, lst_key = _nav_parent(cfg, parent_path)
|
||||
parent_obj[lst_key] = [
|
||||
x for x in parent_obj[lst_key]
|
||||
if str(x.get(item_key, '')) != str(item_value)
|
||||
]
|
||||
elif all_after_null:
|
||||
item = {c['field']: _deserialize_value(c['before'], c['value_type']) for c in changes}
|
||||
_nav_get(cfg, parent_path).append(item)
|
||||
else:
|
||||
item_path = f'{parent_path}[{item_key}={item_value}]' if item_key else parent_path
|
||||
for c in changes:
|
||||
parent_obj, field = _nav_parent(cfg, f'{item_path}.{c["field"]}')
|
||||
if c['before'] is None:
|
||||
parent_obj.pop(field, None)
|
||||
else:
|
||||
parent_obj[field] = _deserialize_value(c['before'], c['value_type'])
|
||||
|
||||
inv = [(c['field'], c['after'], c['before'], c['value_type']) for c in changes]
|
||||
msg = record_group(cfg, parent_path, item_key, item_value, inv,
|
||||
g['cmd'], reverts_group=group_uuid)
|
||||
return msg, True
|
||||
|
||||
|
||||
# Misc ==============================================================
|
||||
|
||||
def run_apply():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue