From 2f2faea835dd8bbf5670392ae94ce95a0de14f09 Mon Sep 17 00:00:00 2001 From: Davide Principi Date: Tue, 9 Jun 2026 19:24:31 +0200 Subject: [PATCH 1/3] feat: run update-core on cluster init and restore When NS8 is installed from a prebuilt image, core and Traefik may be outdated. Add step 40update_cluster to create-cluster to force an update-core run immediately after cluster initialisation. This also covers the disaster-recovery path: restore-cluster already calls create-cluster as a sub-task, so the new step is reached in both scenarios without duplication. During core update the cluster agent receives SIGUSR1 and exits gracefully once all running actions complete, then systemd restarts it at the latest version. Assisted-by: copilot:claude-sonnet-4.6 --- .../actions/create-cluster/00validate_cluster | 4 ++++ .../actions/create-cluster/40update_cluster | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100755 core/imageroot/var/lib/nethserver/cluster/actions/create-cluster/40update_cluster diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/create-cluster/00validate_cluster b/core/imageroot/var/lib/nethserver/cluster/actions/create-cluster/00validate_cluster index 7f4db9ff17..da2d17695d 100755 --- a/core/imageroot/var/lib/nethserver/cluster/actions/create-cluster/00validate_cluster +++ b/core/imageroot/var/lib/nethserver/cluster/actions/create-cluster/00validate_cluster @@ -9,6 +9,10 @@ import json import sys import agent +agent.set_weight("00validate_cluster", 0) +agent.set_weight("10validate_network", 0) +agent.set_weight("40update_cluster", 3) + request = json.load(sys.stdin) rdb = agent.redis_connect() diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/create-cluster/40update_cluster b/core/imageroot/var/lib/nethserver/cluster/actions/create-cluster/40update_cluster new file mode 100755 index 0000000000..d122717641 --- /dev/null +++ b/core/imageroot/var/lib/nethserver/cluster/actions/create-cluster/40update_cluster @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +import sys +import agent +import os + +update_core_response = agent.tasks.run( + agent_id='cluster', + action='update-core', + data={'nodes': [1]}, # always 1 in a new cluster + endpoint="redis://cluster-leader", +) +if update_core_response['exit_code'] != 0: + print(agent.SD_ERR + "update-core failed", file=sys.stderr) + sys.exit(1) From e5978d87cbee94a680dc20b9c56aeda2dc2207d7 Mon Sep 17 00:00:00 2001 From: Davide Principi Date: Wed, 10 Jun 2026 17:51:06 +0200 Subject: [PATCH 2/3] fix: initialized after create-cluster completes The initialized flag was derived solely from cluster/network existing in Redis, which is set early in create-cluster/50update. With the new 40update_cluster step running update-core (and restarting Traefik), a page reload during that window would show the UI fully loaded while the cluster was still being set up. Set FIRST_CONFIGURATION=1 in the cluster agent environment during install-finalize.sh, before any cluster action runs. get-cluster-status returns initialized=false while FIRST_CONFIGURATION=1. At the end of create-cluster/50update, unset_env removes the flag so that subsequent calls to get-cluster-status correctly return initialized=true. Existing clusters have no FIRST_CONFIGURATION key, so the expression os.getenv('FIRST_CONFIGURATION') != '1' evaluates to True and they are unaffected. --- .../lib/nethserver/cluster/actions/create-cluster/50update | 2 ++ .../lib/nethserver/cluster/actions/get-cluster-status/50read | 4 ++-- core/imageroot/var/lib/nethserver/node/install-finalize.sh | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/create-cluster/50update b/core/imageroot/var/lib/nethserver/cluster/actions/create-cluster/50update index 08368de4e7..ad306c4996 100755 --- a/core/imageroot/var/lib/nethserver/cluster/actions/create-cluster/50update +++ b/core/imageroot/var/lib/nethserver/cluster/actions/create-cluster/50update @@ -98,6 +98,8 @@ agent.assert_exp(add1_module_failures == 0) # Update completed successfully, enable the phonehome.timer agent.run_helper(*'systemctl enable --now phonehome.timer'.split(' ')).check_returncode() +agent.unset_env("FIRST_CONFIGURATION") + json.dump({ "ip_address": ip_address, }, fp=sys.stdout) diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/get-cluster-status/50read b/core/imageroot/var/lib/nethserver/cluster/actions/get-cluster-status/50read index 34271d81da..0dbb40511a 100755 --- a/core/imageroot/var/lib/nethserver/cluster/actions/get-cluster-status/50read +++ b/core/imageroot/var/lib/nethserver/cluster/actions/get-cluster-status/50read @@ -70,8 +70,9 @@ leader_id = int(rdb.hget("cluster/environment", "NODE_ID")) leader_endpoint = rdb.hget(f"node/{leader_id}/vpn", "endpoint") or "leader.invalid:55820" local_id = int(os.environ['NODE_ID']) local_public_key = rdb.hget(f"node/{local_id}/vpn", "public_key") +initialized = os.getenv("FIRST_CONFIGURATION") != "1" -ret = { 'initialized': False, 'leader': False, 'nodes': [], 'leader_url': '', 'default_password': False, 'ui_name': cluster_ui_name, } +ret = { 'initialized': initialized, 'leader': False, 'nodes': [], 'leader_url': '', 'default_password': False, 'ui_name': cluster_ui_name, } vpn = {} default_password = hashlib.sha256(b'Nethesis,1234').hexdigest() @@ -82,7 +83,6 @@ for p in admin['passwords']: network = rdb.get('cluster/network') if network is not None: - ret["initialized"] = True # The leader must have a public endpoint: leader_hostname, _ = leader_endpoint.rsplit(":", 1) ret['leader_url'] = f'https://{leader_hostname}/cluster-admin/' diff --git a/core/imageroot/var/lib/nethserver/node/install-finalize.sh b/core/imageroot/var/lib/nethserver/node/install-finalize.sh index a635ade3d9..16c8f5c037 100755 --- a/core/imageroot/var/lib/nethserver/node/install-finalize.sh +++ b/core/imageroot/var/lib/nethserver/node/install-finalize.sh @@ -110,6 +110,7 @@ echo "Grant initial permissions:" runagent python3 <<'EOF' import agent import cluster.grants +agent.set_env("FIRST_CONFIGURATION", "1") rdb = agent.redis_connect(privileged=True) cluster.grants.grant(rdb, action_clause="*", to_clause="owner", on_clause='cluster') cluster.grants.grant(rdb, action_clause="list-*", to_clause="reader", on_clause='cluster') From b1986bb4a43bf23f0d0c72f050eb0cf26722b1aa Mon Sep 17 00:00:00 2001 From: Davide Principi Date: Thu, 18 Jun 2026 11:28:13 +0200 Subject: [PATCH 3/3] fix(ui): retry cluster task on 404 Retry the cluster status task submission when api-server returns "client idle check failed" during cluster agent restarts. Handle the retry in the shared axios interceptor and scope it to /cluster/tasks so concurrent node or module task requests do not consume the same retry budget. Assisted-by: copilot:gpt-5.4 --- core/ui/src/App.vue | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/core/ui/src/App.vue b/core/ui/src/App.vue index 0aa0a7a596..29af6e3117 100644 --- a/core/ui/src/App.vue +++ b/core/ui/src/App.vue @@ -19,7 +19,6 @@ import ShellHeader from "@/components/shell/ShellHeader"; import SideMenu from "@/components/shell/SideMenu"; import MobileSideMenu from "@/components/shell/MobileSideMenu"; -import axios from "axios"; import WebSocketService from "@/mixins/websocket"; import { mapState, mapActions } from "vuex"; import to from "await-to-js"; @@ -187,15 +186,39 @@ export default { } }); }, + isRetryableAgentRequest(error) { + return ( + // The cluster agent may be temporarily unavailable while restarting. + error.config?.method?.toLowerCase() === "post" && + error.response?.status == 404 && + error.response?.data?.message === "client idle check failed" && + error.config?.url?.endsWith("/cluster/tasks") + ); + }, configureAxiosInterceptors() { const context = this; + const maxAgentRestartRetries = 5; // response interceptor - axios.interceptors.response.use( + this.axios.interceptors.response.use( function (response) { return response; }, - function (error) { + async function (error) { + if (context.isRetryableAgentRequest(error)) { + const retryConfig = error.config; + retryConfig.__agentRestartRetryCount = + (retryConfig.__agentRestartRetryCount || 0) + 1; + + if ( + retryConfig.__agentRestartRetryCount <= + maxAgentRestartRetries + ) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + return context.axios.request(retryConfig); + } + } + console.error(error); // print specific error message, if available