diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/list-backups/50list b/core/imageroot/var/lib/nethserver/cluster/actions/list-backups/50list index 70553e7d91..980d7b2745 100755 --- a/core/imageroot/var/lib/nethserver/cluster/actions/list-backups/50list +++ b/core/imageroot/var/lib/nethserver/cluster/actions/list-backups/50list @@ -66,6 +66,9 @@ for bid, backup in backups.items(): instances = rdb.hget(f"cluster/backup/{bid}", "instances") or "" for mid in instances.split(): module_uuid = rdb.hget("cluster/module_uuid", mid) + if module_uuid is None: + # Skip stale references to modules removed from the cluster + continue path_prefix = agent.get_image_name_from_url(rdb.hget(f"module/{mid}/environment", "IMAGE_URL") or "/:") status = backup_statuses.get((bid, mid)) if status is not None: diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/remove-module/50update b/core/imageroot/var/lib/nethserver/cluster/actions/remove-module/50update index 891cdbcf24..8898300c17 100755 --- a/core/imageroot/var/lib/nethserver/cluster/actions/remove-module/50update +++ b/core/imageroot/var/lib/nethserver/cluster/actions/remove-module/50update @@ -114,6 +114,15 @@ rdb.delete(f'cluster/authorizations/module/{module_id}') rdb.hdel('cluster/module_node', module_id) rdb.hdel('cluster/module_uuid', module_id) +# Remove the module from any backup schedule and purge its backup status +for kbackup in rdb.scan_iter('cluster/backup/*'): + instances = (rdb.hget(kbackup, 'instances') or '').split() + if module_id in instances: + instances.remove(module_id) + rdb.hset(kbackup, 'instances', ' '.join(instances)) +for kstatus in rdb.scan_iter('node/*/backup_status/*'): + rdb.hdel(kstatus, module_id) + # Revoke module permissions, clean up permissions of other modules rdb.delete(f'roles/module/{module_id}') permpipe = rdb.pipeline() diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/remove-node/50remove_node b/core/imageroot/var/lib/nethserver/cluster/actions/remove-node/50remove_node index cf55e67919..bdc5f030df 100755 --- a/core/imageroot/var/lib/nethserver/cluster/actions/remove-node/50remove_node +++ b/core/imageroot/var/lib/nethserver/cluster/actions/remove-node/50remove_node @@ -44,10 +44,25 @@ for xmodule_id, xnode_id in rdb.hgetall('cluster/module_node').items(): # Save ACLs on the disk and propagate to worker nodes cluster.grants.save_acls(rdb) +# Collect the backup schedule/status changes needed to forget the removed modules +backup_updates = {} # {cluster/backup/ key: trimmed "instances" string} +backup_status_keys = [] +if remove_module_set: + for kbackup in rdb.scan_iter('cluster/backup/*'): + instances_set = set((rdb.hget(kbackup, 'instances') or '').split()) + if instances_set.intersection(remove_module_set): + backup_updates[kbackup] = ' '.join(sorted(instances_set.difference(remove_module_set))) + backup_status_keys = list(rdb.scan_iter('node/*/backup_status/*')) + trx = rdb.pipeline() if remove_module_set: trx.hdel("cluster/module_node", *remove_module_set) trx.hdel("cluster/module_domains", *remove_module_set) + trx.hdel("cluster/module_uuid", *remove_module_set) + for kbackup, instances in backup_updates.items(): + trx.hset(kbackup, 'instances', instances) + for kstatus in backup_status_keys: + trx.hdel(kstatus, *remove_module_set) for xmodule_id in remove_module_set: trx.publish('cluster/event/module-removed', json.dumps({ 'module': xmodule_id, diff --git a/core/imageroot/var/lib/nethserver/cluster/update-core-pre-modules.d/60prune_removed_module_backups b/core/imageroot/var/lib/nethserver/cluster/update-core-pre-modules.d/60prune_removed_module_backups new file mode 100755 index 0000000000..b305633099 --- /dev/null +++ b/core/imageroot/var/lib/nethserver/cluster/update-core-pre-modules.d/60prune_removed_module_backups @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +import agent + +# +# Bug NethServer/dev#8094: modules removed from the cluster before backup +# schedule cleanup was implemented were left dangling in backup schedules, +# backup status entries and cluster/module_uuid. Prune them, using +# cluster/module_node as the authoritative source of live module ids. +# + +rdb = agent.redis_connect(privileged=True) +live_modules = set(rdb.hkeys('cluster/module_node')) + +trx = rdb.pipeline() + +dangling_mids = [mid for mid in rdb.hkeys('cluster/module_uuid') if mid not in live_modules] +if dangling_mids: + trx.hdel('cluster/module_uuid', *dangling_mids) + +for kbackup in rdb.scan_iter('cluster/backup/*'): + instances_set = set((rdb.hget(kbackup, 'instances') or '').split()) + if not instances_set.issubset(live_modules): + trx.hset(kbackup, 'instances', ' '.join(sorted(instances_set.intersection(live_modules)))) + +for kstatus in rdb.scan_iter('node/*/backup_status/*'): + dangling_mid_hkeys = [mid for mid in rdb.hkeys(kstatus) if mid not in live_modules] + if dangling_mid_hkeys: + trx.hdel(kstatus, *dangling_mid_hkeys) + +trx.execute()