From eaef4c2149c9ef8c32fa74fceb58cc7eb783cf8a Mon Sep 17 00:00:00 2001 From: 20vikash Date: Fri, 1 May 2026 14:43:06 +0530 Subject: [PATCH 01/59] feat(server): Scaffold setup_agent_auth --- press/press/doctype/server/server.js | 6 ++++++ press/press/doctype/server/server.py | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/press/press/doctype/server/server.js b/press/press/doctype/server/server.js index 472bf9f652d..6d582e27469 100644 --- a/press/press/doctype/server/server.js +++ b/press/press/doctype/server/server.js @@ -167,6 +167,12 @@ frappe.ui.form.on('Server', { false, frm.doc.is_server_setup, ], + [ + __('Setup Agent Auth'), + 'setup_agent_auth', + false, + frm.doc.is_server_setup, + ], [ __('Start Active Benches'), 'start_active_benches', diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index 25a904312ad..84558f104b5 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -3188,6 +3188,13 @@ def _setup_agent_sentry(self): except Exception: log_error("Agent Sentry Setup Exception", server=self.as_dict()) + @frappe.whitelist() + def setup_agent_auth(self): + frappe.enqueue(self.doctype, self.name, "_setup_agent_auth", queue="long") + + def _setup_agent_auth(self): + pass + @frappe.whitelist() def whitelist_ipaddress(self): frappe.enqueue_doc(self.doctype, self.name, "_whitelist_ip", queue="short", timeout=1200) From b6454ef1873b4fd0c42bd6c85fd3e8d75194de4c Mon Sep 17 00:00:00 2001 From: 20vikash Date: Fri, 1 May 2026 16:46:25 +0530 Subject: [PATCH 02/59] feat(server): Generate ED25519 key pair, set private key to agent --- press/press/doctype/server/server.json | 25 ++++++++++----- press/press/doctype/server/server.py | 43 ++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/press/press/doctype/server/server.json b/press/press/doctype/server/server.json index 84ee07da0c5..6fe3d6d6d39 100644 --- a/press/press/doctype/server/server.json +++ b/press/press/doctype/server/server.json @@ -34,7 +34,6 @@ "is_for_recovery", "is_monitoring_disabled", "enable_on_prem_failover_support", - "stop_incident_actions", "billing_section", "team", "plan", @@ -53,8 +52,10 @@ "private_vlan_id", "agent_section", "agent_password", + "public_key", "column_break_pdbx", "disable_agent_job_auto_retry", + "is_agent_auth_setup", "reverse_proxy_section", "proxy_server", "column_break_12", @@ -224,7 +225,8 @@ "default": "0", "fieldname": "use_for_new_benches", "fieldtype": "Check", - "label": "Use For New Benches" + "label": "Use For New Benches", + "read_only": 1 }, { "fieldname": "hostname", @@ -245,7 +247,8 @@ "default": "0", "fieldname": "use_for_new_sites", "fieldtype": "Check", - "label": "Use For New Sites" + "label": "Use For New Sites", + "read_only": 1 }, { "fieldname": "cluster", @@ -755,11 +758,18 @@ "fieldtype": "Check", "label": "Enable On-Prem Failover Support" }, + { + "fieldname": "public_key", + "fieldtype": "Data", + "label": "Public Key", + "read_only": 1 + }, { "default": "0", - "fieldname": "stop_incident_actions", + "fieldname": "is_agent_auth_setup", "fieldtype": "Check", - "label": "Stop Incident Actions" + "label": "Is Agent Auth Setup", + "read_only": 1 } ], "links": [ @@ -772,7 +782,7 @@ "link_fieldname": "app_server" } ], - "modified": "2026-04-28 18:08:02.595325", + "modified": "2026-05-01 16:36:38.933131", "modified_by": "Administrator", "module": "Press", "name": "Server", @@ -804,10 +814,9 @@ } ], "row_format": "Dynamic", - "rows_threshold_for_grid_search": 20, "sort_field": "modified", "sort_order": "DESC", "states": [], "title_field": "title", "track_changes": 1 -} \ No newline at end of file +} diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index 84558f104b5..8c2c6f18d02 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -2688,6 +2688,7 @@ class Server(BaseServer): ignore_incidents_till: DF.Datetime | None ip: DF.Data | None ipv6: DF.Data | None + is_agent_auth_setup: DF.Check is_for_recovery: DF.Check is_managed_database: DF.Check is_monitoring_disabled: DF.Check @@ -2721,6 +2722,7 @@ class Server(BaseServer): ] proxy_server: DF.Link | None public: DF.Check + public_key: DF.Data | None ram: DF.Float root_public_key: DF.Code | None scaled_up: DF.Check @@ -2735,7 +2737,6 @@ class Server(BaseServer): staging: DF.Check status: DF.Literal["Pending", "Installing", "Active", "Broken", "Archived"] stop_deployments: DF.Check - stop_incident_actions: DF.Check tags: DF.Table[ResourceTag] team: DF.Link | None title: DF.Data | None @@ -3192,8 +3193,46 @@ def _setup_agent_sentry(self): def setup_agent_auth(self): frappe.enqueue(self.doctype, self.name, "_setup_agent_auth", queue="long") + def _generate_and_activate_key(self, regenerate: bool = False) -> str | None: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ed25519 + + if self.public_key and self.is_agent_auth_setup and not regenerate: + return None + + key = ed25519.Ed25519PrivateKey.generate() + + private_key_str = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.OpenSSH, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + + self.public_key = ( + key.public_key() + .public_bytes(encoding=serialization.Encoding.OpenSSH, format=serialization.PublicFormat.OpenSSH) + .decode() + ) + self.save() + + return private_key_str + def _setup_agent_auth(self): - pass + try: + private_key = self._generate_and_activate_key() + + ansible = Ansible( + playbook="setup_agent_auth.yml", + server=self, + user=self._ssh_user(), + port=self._ssh_port(), + variables={"private_key": private_key}, + ) + ansible.run() + self.is_agent_auth_setup = 1 + except Exception: + log_error("Agent Auth Setup Exception", server=self.as_dict()) + self.save() @frappe.whitelist() def whitelist_ipaddress(self): From d6e8876ebed3716c45e08b1cfb1b2a828d1269c4 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Fri, 1 May 2026 17:54:28 +0530 Subject: [PATCH 03/59] feat(server): Playbook for agent auth --- press/playbooks/roles/setup_agent_auth/tasks/main.yml | 6 ++++++ press/playbooks/setup_agent_auth.yml | 8 ++++++++ press/press/doctype/server/server.json | 2 +- press/press/doctype/server/server.py | 2 +- 4 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 press/playbooks/roles/setup_agent_auth/tasks/main.yml create mode 100644 press/playbooks/setup_agent_auth.yml diff --git a/press/playbooks/roles/setup_agent_auth/tasks/main.yml b/press/playbooks/roles/setup_agent_auth/tasks/main.yml new file mode 100644 index 00000000000..37f89902104 --- /dev/null +++ b/press/playbooks/roles/setup_agent_auth/tasks/main.yml @@ -0,0 +1,6 @@ +--- +- name: Write private key + copy: + content: "{{ private_key }}" + dest: "/home/frappe/agent/private.key" + mode: '0600' diff --git a/press/playbooks/setup_agent_auth.yml b/press/playbooks/setup_agent_auth.yml new file mode 100644 index 00000000000..63364037949 --- /dev/null +++ b/press/playbooks/setup_agent_auth.yml @@ -0,0 +1,8 @@ +--- +- name: Setup agent auth + hosts: all + become: yes + become_user: frappe + gather_facts: no + roles: + - role: setup_agent_auth diff --git a/press/press/doctype/server/server.json b/press/press/doctype/server/server.json index 6fe3d6d6d39..2ec53e9cedf 100644 --- a/press/press/doctype/server/server.json +++ b/press/press/doctype/server/server.json @@ -782,7 +782,7 @@ "link_fieldname": "app_server" } ], - "modified": "2026-05-01 16:36:38.933131", + "modified": "2026-05-01 17:52:02.730482", "modified_by": "Administrator", "module": "Press", "name": "Server", diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index 8c2c6f18d02..f37dc2042e3 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -3191,7 +3191,7 @@ def _setup_agent_sentry(self): @frappe.whitelist() def setup_agent_auth(self): - frappe.enqueue(self.doctype, self.name, "_setup_agent_auth", queue="long") + frappe.enqueue_doc(self.doctype, self.name, "_setup_agent_auth", queue="long", timeout=1200) def _generate_and_activate_key(self, regenerate: bool = False) -> str | None: from cryptography.hazmat.primitives import serialization From 567135f15c17742c102a55d0620f9cf52f84a61f Mon Sep 17 00:00:00 2001 From: 20vikash Date: Fri, 1 May 2026 19:35:40 +0530 Subject: [PATCH 04/59] feat(agent): Verify response token --- press/agent.py | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/press/agent.py b/press/agent.py index a0b05ea2d49..7b6252a9d51 100644 --- a/press/agent.py +++ b/press/agent.py @@ -3,9 +3,11 @@ from __future__ import annotations import _io # type: ignore +import base64 import json import os import re +import time from contextlib import suppress from datetime import date from typing import TYPE_CHECKING, Any, Literal @@ -13,6 +15,7 @@ import frappe import frappe.utils import requests +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from frappe.utils.password import get_decrypted_password from requests.exceptions import HTTPError @@ -949,6 +952,81 @@ def _make_req(self, method, path, data, files, agent_job_id): return requests.request(method, url, headers=headers, files=file_objects, verify=verify) return requests.request(method, url, headers=headers, json=data, verify=verify, timeout=(10, 30)) + def verify_response_token(self, token: dict, payload, method: str, path: str): + try: + timestamp = int(token["timestamp"]) + nonce = token["nonce"] + signature_b64 = token["signature"] + except KeyError as err: + raise ValueError("Invalid token structure") from err + + # timestamp validation + now = int(time.time()) + WINDOW = 30 + + if timestamp > now + 5: + raise ValueError("Token from future") + + if now - timestamp > WINDOW: + raise ValueError("Token expired") + + # fetch public key + agent = frappe.get_doc("Server", self.server) + + if not agent.public_key: + raise ValueError("No public key registered") + + public_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(agent.public_key.split()[1])) + + # reconstruct signed message + message = json.dumps( + { + "method": method, + "path": path, + "timestamp": timestamp, + "nonce": nonce, + "payload": payload, + }, + separators=(",", ":"), + sort_keys=True, + ).encode() + + signature = base64.b64decode(signature_b64) + + # verify signature + try: + public_key.verify(signature, message) + except Exception as err: + raise ValueError("Invalid signature") from err + + # replay protection + cache_key = f"nonce:{self.server}:{nonce}" + + if frappe.cache().get_value(cache_key): + raise ValueError("Replay attack detected") + + frappe.cache().set_value(cache_key, 1, expires_in_sec=60) + + return True + + def _extract_and_verify_token(self, response, json_response, method, path): + token_str = response.headers.get("X-Agent-Token") + + if not token_str: + raise ValueError("Unsigned response from agent") + + try: + token = json.loads(base64.b64decode(token_str)) + except Exception as err: + raise ValueError("Invalid token encoding") from err + + self.verify_response_token( + token=token, + payload=json_response, + method=method, + path=path, + ) + def request(self, method, path, data=None, files=None, agent_job=None, raises=True): self.raise_if_past_requests_have_failed() response = json_response = None @@ -956,6 +1034,9 @@ def request(self, method, path, data=None, files=None, agent_job=None, raises=Tr agent_job_id = agent_job.name if agent_job else None response = self._make_req(method, path, data, files, agent_job_id) json_response = response.json() + + self._extract_and_verify_token(response, json_response, method, path) + if raises and response.status_code >= 400: output = "\n\n".join([json_response.get("output", ""), json_response.get("traceback", "")]) if output == "\n\n": From 47d2b04cc95af3d818d3fe07abfabdd20ec799b7 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sat, 2 May 2026 11:36:05 +0000 Subject: [PATCH 05/59] refactor(server): Use raw format for public key, pkcs for private --- .cspell.json | 1 + press/press/doctype/server/server.json | 2 +- press/press/doctype/server/server.py | 14 +++++++++----- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.cspell.json b/.cspell.json index 14a518c6fef..a7eb32d7238 100644 --- a/.cspell.json +++ b/.cspell.json @@ -483,6 +483,7 @@ "phpmyadmin", "pids", "Pjpw", + "pkcs", "pkgs", "pmadb", "pmezard", diff --git a/press/press/doctype/server/server.json b/press/press/doctype/server/server.json index 2ec53e9cedf..9c3be778b54 100644 --- a/press/press/doctype/server/server.json +++ b/press/press/doctype/server/server.json @@ -782,7 +782,7 @@ "link_fieldname": "app_server" } ], - "modified": "2026-05-01 17:52:02.730482", + "modified": "2026-05-02 16:31:13.693849", "modified_by": "Administrator", "module": "Press", "name": "Server", diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index f37dc2042e3..77e13578f32 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -3,6 +3,7 @@ from __future__ import annotations +import base64 import contextlib import datetime import ipaddress @@ -3204,15 +3205,18 @@ def _generate_and_activate_key(self, regenerate: bool = False) -> str | None: private_key_str = key.private_bytes( encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.OpenSSH, + format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption(), ).decode() - self.public_key = ( - key.public_key() - .public_bytes(encoding=serialization.Encoding.OpenSSH, format=serialization.PublicFormat.OpenSSH) - .decode() + public_key_bytes = key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, ) + + public_key_b64 = base64.b64encode(public_key_bytes).decode() + + self.public_key = public_key_b64 self.save() return private_key_str From bc16af3b3776a853ebe2f132faeb2ffbaf9e62c7 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sat, 2 May 2026 11:42:13 +0000 Subject: [PATCH 06/59] refactor(agent): Also verify response token in raw_request --- press/agent.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/press/agent.py b/press/agent.py index 7b6252a9d51..250b84846d6 100644 --- a/press/agent.py +++ b/press/agent.py @@ -952,7 +952,7 @@ def _make_req(self, method, path, data, files, agent_job_id): return requests.request(method, url, headers=headers, files=file_objects, verify=verify) return requests.request(method, url, headers=headers, json=data, verify=verify, timeout=(10, 30)) - def verify_response_token(self, token: dict, payload, method: str, path: str): + def _verify_response_token(self, token: dict, payload, method: str, path: str): try: timestamp = int(token["timestamp"]) nonce = token["nonce"] @@ -976,7 +976,9 @@ def verify_response_token(self, token: dict, payload, method: str, path: str): if not agent.public_key: raise ValueError("No public key registered") - public_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(agent.public_key.split()[1])) + public_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(agent.public_key)) + + path = f"/{path.lstrip('/')}" # reconstruct signed message message = json.dumps( @@ -1009,7 +1011,7 @@ def verify_response_token(self, token: dict, payload, method: str, path: str): return True - def _extract_and_verify_token(self, response, json_response, method, path): + def extract_and_verify_token(self, response, json_response, method, path): token_str = response.headers.get("X-Agent-Token") if not token_str: @@ -1020,7 +1022,7 @@ def _extract_and_verify_token(self, response, json_response, method, path): except Exception as err: raise ValueError("Invalid token encoding") from err - self.verify_response_token( + self._verify_response_token( token=token, payload=json_response, method=method, @@ -1035,7 +1037,7 @@ def request(self, method, path, data=None, files=None, agent_job=None, raises=Tr response = self._make_req(method, path, data, files, agent_job_id) json_response = response.json() - self._extract_and_verify_token(response, json_response, method, path) + self.extract_and_verify_token(response, json_response, method, path) if raises and response.status_code >= 400: output = "\n\n".join([json_response.get("output", ""), json_response.get("traceback", "")]) @@ -1107,6 +1109,9 @@ def raw_request(self, method, path, data=None, raises=True, timeout=None): timeout = timeout or (10, 30) response = requests.request(method, url, headers=headers, json=data, timeout=timeout) json_response = response.json() + + self.extract_and_verify_token(response, json_response, method, path) + if raises: response.raise_for_status() return json_response From 2db84d81cb9b18d7b94d0ae63f37eda7853a9635 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sat, 2 May 2026 12:21:40 +0000 Subject: [PATCH 07/59] feat(server): Setup Agent Auth in proxy and database --- press/press/doctype/database_server/database_server.js | 6 ++++++ press/press/doctype/proxy_server/proxy_server.js | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/press/press/doctype/database_server/database_server.js b/press/press/doctype/database_server/database_server.js index 9b471179cf0..868eb12c7e5 100644 --- a/press/press/doctype/database_server/database_server.js +++ b/press/press/doctype/database_server/database_server.js @@ -24,6 +24,12 @@ frappe.ui.form.on('Database Server', { true, frm.doc.is_server_setup, ], + [ + __('Setup Agent Auth'), + 'setup_agent_auth', + false, + frm.doc.is_server_setup, + ], [ __('Install Filebeat'), 'install_filebeat', diff --git a/press/press/doctype/proxy_server/proxy_server.js b/press/press/doctype/proxy_server/proxy_server.js index e04fd5f0ece..b641e1c53e1 100644 --- a/press/press/doctype/proxy_server/proxy_server.js +++ b/press/press/doctype/proxy_server/proxy_server.js @@ -14,6 +14,12 @@ frappe.ui.form.on('Proxy Server', { true, frm.doc.is_server_setup, ], + [ + __('Setup Agent Auth'), + 'setup_agent_auth', + false, + frm.doc.is_server_setup, + ], [ __('Install Filebeat'), 'install_filebeat', From 62628f5681cd42bf5c360e14fccd76f13a989615 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sat, 2 May 2026 15:05:10 +0000 Subject: [PATCH 08/59] fix(server): Move ED25519 key generation to BaseServer --- press/press/doctype/server/server.py | 91 ++++++++++++++-------------- 1 file changed, 47 insertions(+), 44 deletions(-) diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index 77e13578f32..b30735bc1e9 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -93,6 +93,9 @@ class AutoScaleTriggerRow(TypedDict): class BaseServer(Document, TagHelpers): + public_key: str | None + is_agent_auth_setup: int | None + dashboard_fields = ( "title", "plan", @@ -1897,6 +1900,50 @@ def _get_dependency_version(self, candidate: str, dependency: str) -> str: "version", ) + def _generate_and_activate_key(self, regenerate: bool = False) -> str | None: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ed25519 + + if self.public_key and self.is_agent_auth_setup and not regenerate: + return None + + key = ed25519.Ed25519PrivateKey.generate() + + private_key_str = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + + public_key_bytes = key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + + public_key_b64 = base64.b64encode(public_key_bytes).decode() + + self.public_key = public_key_b64 + self.save() + + return private_key_str + + def _setup_agent_auth(self): + try: + private_key = self._generate_and_activate_key() + + ansible = Ansible( + playbook="setup_agent_auth.yml", + server=self, + user=self._ssh_user(), + port=self._ssh_port(), + variables={"private_key": private_key}, + ) + ansible.run() + self.is_agent_auth_setup = 1 + except Exception: + log_error("Agent Auth Setup Exception", server=self.as_dict()) + self.save() + @frappe.whitelist() def collect_arm_images(self) -> str: """Collect arm build images of all active benches on VM""" @@ -3194,50 +3241,6 @@ def _setup_agent_sentry(self): def setup_agent_auth(self): frappe.enqueue_doc(self.doctype, self.name, "_setup_agent_auth", queue="long", timeout=1200) - def _generate_and_activate_key(self, regenerate: bool = False) -> str | None: - from cryptography.hazmat.primitives import serialization - from cryptography.hazmat.primitives.asymmetric import ed25519 - - if self.public_key and self.is_agent_auth_setup and not regenerate: - return None - - key = ed25519.Ed25519PrivateKey.generate() - - private_key_str = key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ).decode() - - public_key_bytes = key.public_key().public_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PublicFormat.Raw, - ) - - public_key_b64 = base64.b64encode(public_key_bytes).decode() - - self.public_key = public_key_b64 - self.save() - - return private_key_str - - def _setup_agent_auth(self): - try: - private_key = self._generate_and_activate_key() - - ansible = Ansible( - playbook="setup_agent_auth.yml", - server=self, - user=self._ssh_user(), - port=self._ssh_port(), - variables={"private_key": private_key}, - ) - ansible.run() - self.is_agent_auth_setup = 1 - except Exception: - log_error("Agent Auth Setup Exception", server=self.as_dict()) - self.save() - @frappe.whitelist() def whitelist_ipaddress(self): frappe.enqueue_doc(self.doctype, self.name, "_whitelist_ip", queue="short", timeout=1200) From d841835bd751d6454ff6adb17a8bc39cfb4d7ea4 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sat, 2 May 2026 15:36:29 +0000 Subject: [PATCH 09/59] feat(server): Update Proxy and Database DOCTYPE --- .../database_server/database_server.json | 22 ++++++++++++++++++- .../database_server/database_server.py | 6 +++++ .../doctype/proxy_server/proxy_server.json | 17 +++++++++++++- .../doctype/proxy_server/proxy_server.py | 6 +++++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/press/press/doctype/database_server/database_server.json b/press/press/doctype/database_server/database_server.json index 9c5980383a4..60d0fbd602c 100644 --- a/press/press/doctype/database_server/database_server.json +++ b/press/press/doctype/database_server/database_server.json @@ -47,6 +47,9 @@ "private_vlan_id", "agent_section", "agent_password", + "public_key", + "column_break_zpnv", + "is_agent_auth_setup", "mariadb_section", "self_hosted_mariadb_server", "mariadb_root_password", @@ -787,6 +790,23 @@ "fieldtype": "Link", "label": "NAT Server", "options": "NAT Server" + }, + { + "fieldname": "column_break_zpnv", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "is_agent_auth_setup", + "fieldtype": "Check", + "label": "Is Agent Auth Setup", + "read_only": 1 + }, + { + "fieldname": "public_key", + "fieldtype": "Data", + "label": "Public Key", + "read_only": 1 } ], "grid_page_length": 50, @@ -803,7 +823,7 @@ "link_fieldname": "database_server" } ], - "modified": "2026-03-18 03:08:29.199702", + "modified": "2026-05-02 20:52:27.931597", "modified_by": "Administrator", "module": "Press", "name": "Database Server", diff --git a/press/press/doctype/database_server/database_server.py b/press/press/doctype/database_server/database_server.py index 30ed747284a..6043d9d2540 100644 --- a/press/press/doctype/database_server/database_server.py +++ b/press/press/doctype/database_server/database_server.py @@ -77,6 +77,7 @@ class DatabaseServer(BaseServer): hostname: DF.Data hostname_abbreviation: DF.Data | None ip: DF.Data | None + is_agent_auth_setup: DF.Check is_binlog_indexer_running: DF.Check is_for_recovery: DF.Check is_monitoring_disabled: DF.Check @@ -108,6 +109,7 @@ class DatabaseServer(BaseServer): "Generic", "Scaleway", "AWS EC2", "OCI", "Hetzner", "Vodacom", "DigitalOcean", "Frappe Compute" ] public: DF.Check + public_key: DF.Data | None ram: DF.Float root_public_key: DF.Code | None self_hosted_mariadb_server: DF.Data | None @@ -525,6 +527,10 @@ def _update_mariadb_system_variables(self, variables: list[DatabaseServerMariaDB if restart: self._restart_mariadb() + @frappe.whitelist() + def setup_agent_auth(self): + frappe.enqueue_doc(self.doctype, self.name, "_setup_agent_auth", queue="long", timeout=1200) + @frappe.whitelist() def restart_mariadb(self): frappe.enqueue_doc(self.doctype, self.name, "_restart_mariadb") diff --git a/press/press/doctype/proxy_server/proxy_server.json b/press/press/doctype/proxy_server/proxy_server.json index 6425969daa6..21c4220488f 100644 --- a/press/press/doctype/proxy_server/proxy_server.json +++ b/press/press/doctype/proxy_server/proxy_server.json @@ -36,9 +36,11 @@ "private_vlan_id", "agent_section", "agent_password", + "public_key", "column_break_mznm", "disable_agent_job_auto_retry", "use_as_proxy_for_agent_and_metrics", + "is_agent_auth_setup", "replica_section", "is_primary", "primary", @@ -495,10 +497,23 @@ "fieldname": "use_as_proxy_for_agent_and_metrics", "fieldtype": "Check", "label": "Use as Proxy for Agent and Metrics" + }, + { + "fieldname": "public_key", + "fieldtype": "Data", + "label": "Public Key", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_agent_auth_setup", + "fieldtype": "Check", + "label": "Is Agent Auth Setup", + "read_only": 1 } ], "links": [], - "modified": "2026-03-15 19:01:43.557830", + "modified": "2026-05-02 18:34:57.231059", "modified_by": "Administrator", "module": "Press", "name": "Proxy Server", diff --git a/press/press/doctype/proxy_server/proxy_server.py b/press/press/doctype/proxy_server/proxy_server.py index 297b828a0d7..d97b9384184 100644 --- a/press/press/doctype/proxy_server/proxy_server.py +++ b/press/press/doctype/proxy_server/proxy_server.py @@ -41,6 +41,7 @@ class ProxyServer(BaseServer): hostname: DF.Data hostname_abbreviation: DF.Data | None ip: DF.Data | None + is_agent_auth_setup: DF.Check is_primary: DF.Check is_proxysql_setup: DF.Check is_replication_setup: DF.Check @@ -62,6 +63,7 @@ class ProxyServer(BaseServer): proxysql_admin_password: DF.Password | None proxysql_monitor_password: DF.Password | None public: DF.Check + public_key: DF.Data | None root_public_key: DF.Code | None self_hosted_server_domain: DF.Data | None ssh_certificate_authority: DF.Link | None @@ -176,6 +178,10 @@ def _install_exporters(self): except Exception: log_error("Exporters Install Exception", server=self.as_dict()) + @frappe.whitelist() + def setup_agent_auth(self): + frappe.enqueue_doc(self.doctype, self.name, "_setup_agent_auth", queue="long", timeout=1200) + @frappe.whitelist() def setup_ssh_proxy(self): if not self.ssh_certificate_authority: From f82609a94a841ec34805821322fbe1ae809c82b9 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sun, 3 May 2026 05:49:40 +0000 Subject: [PATCH 10/59] refactor(server): Move setup_agent_auth whitelist to BaseServer --- press/press/doctype/server/server.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index b30735bc1e9..a7756276bed 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -1927,6 +1927,10 @@ def _generate_and_activate_key(self, regenerate: bool = False) -> str | None: return private_key_str + @frappe.whitelist() + def setup_agent_auth(self): + frappe.enqueue_doc(self.doctype, self.name, "_setup_agent_auth", queue="long", timeout=1200) + def _setup_agent_auth(self): try: private_key = self._generate_and_activate_key() @@ -3237,10 +3241,6 @@ def _setup_agent_sentry(self): except Exception: log_error("Agent Sentry Setup Exception", server=self.as_dict()) - @frappe.whitelist() - def setup_agent_auth(self): - frappe.enqueue_doc(self.doctype, self.name, "_setup_agent_auth", queue="long", timeout=1200) - @frappe.whitelist() def whitelist_ipaddress(self): frappe.enqueue_doc(self.doctype, self.name, "_whitelist_ip", queue="short", timeout=1200) From 56a55229d30860c9dc5ffea2bbd09dea52b6b47e Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sun, 3 May 2026 05:52:30 +0000 Subject: [PATCH 11/59] feat(server): Setup Agent Auth in server setup time --- press/playbooks/database.yml | 1 + press/playbooks/proxy.yml | 1 + press/playbooks/self_hosted.yml | 1 + press/playbooks/self_hosted_db.yml | 1 + press/playbooks/self_hosted_proxy.yml | 1 + press/playbooks/server.yml | 1 + press/press/doctype/database_server/database_server.py | 7 +++---- press/press/doctype/proxy_server/proxy_server.py | 8 ++++---- press/press/doctype/server/server.py | 3 +++ 9 files changed, 16 insertions(+), 8 deletions(-) diff --git a/press/playbooks/database.yml b/press/playbooks/database.yml index fc13c738dfe..990c4e00975 100644 --- a/press/playbooks/database.yml +++ b/press/playbooks/database.yml @@ -13,6 +13,7 @@ - role: mariadb_memory_allocator - role: nginx - role: agent + - role: setup_agent_auth - role: node_exporter - role: mysqld_exporter - role: deadlock_logger diff --git a/press/playbooks/proxy.yml b/press/playbooks/proxy.yml index 6624eafa5c8..f7ef593876c 100644 --- a/press/playbooks/proxy.yml +++ b/press/playbooks/proxy.yml @@ -9,6 +9,7 @@ - role: user - role: nginx - role: agent + - role: setup_agent_auth - role: proxy - role: node_exporter - role: user_ssh_certificate diff --git a/press/playbooks/self_hosted.yml b/press/playbooks/self_hosted.yml index 541dffd3380..42e56ddb64a 100644 --- a/press/playbooks/self_hosted.yml +++ b/press/playbooks/self_hosted.yml @@ -9,6 +9,7 @@ - role: user - role: nginx - role: agent + - role: setup_agent_auth - role: bench - role: docker - role: node_exporter diff --git a/press/playbooks/self_hosted_db.yml b/press/playbooks/self_hosted_db.yml index 8d2aa491a37..b9291e67923 100644 --- a/press/playbooks/self_hosted_db.yml +++ b/press/playbooks/self_hosted_db.yml @@ -10,6 +10,7 @@ - role: mariadb - role: nginx - role: agent + - role: setup_agent_auth - role: node_exporter - role: mysqld_exporter - role: deadlock_logger diff --git a/press/playbooks/self_hosted_proxy.yml b/press/playbooks/self_hosted_proxy.yml index 9754455dfbb..4413b31f069 100644 --- a/press/playbooks/self_hosted_proxy.yml +++ b/press/playbooks/self_hosted_proxy.yml @@ -9,5 +9,6 @@ - role: user - role: nginx - role: agent + - role: setup_agent_auth - role: proxy - role: docker diff --git a/press/playbooks/server.yml b/press/playbooks/server.yml index 12daffa5a80..40e8a4725cd 100644 --- a/press/playbooks/server.yml +++ b/press/playbooks/server.yml @@ -10,6 +10,7 @@ - role: user - role: nginx - role: agent + - role: setup_agent_auth - role: mount - role: bench - role: docker diff --git a/press/press/doctype/database_server/database_server.py b/press/press/doctype/database_server/database_server.py index 6043d9d2540..8f7d7dd4833 100644 --- a/press/press/doctype/database_server/database_server.py +++ b/press/press/doctype/database_server/database_server.py @@ -527,10 +527,6 @@ def _update_mariadb_system_variables(self, variables: list[DatabaseServerMariaDB if restart: self._restart_mariadb() - @frappe.whitelist() - def setup_agent_auth(self): - frappe.enqueue_doc(self.doctype, self.name, "_setup_agent_auth", queue="long", timeout=1200) - @frappe.whitelist() def restart_mariadb(self): frappe.enqueue_doc(self.doctype, self.name, "_restart_mariadb") @@ -829,6 +825,7 @@ def _setup_server(self): "monitoring_password": config.monitoring_password, "log_server": config.log_server, "kibana_password": config.kibana_password, + "private_key": config.private_key, "private_ip": self.private_ip, "server_id": self.server_id, "allocator": self.memory_allocator.lower(), @@ -848,6 +845,7 @@ def _setup_server(self): if play.status == "Success": self.status = "Active" self.is_server_setup = True + self.is_agent_auth_setup = 1 self.process_hybrid_server_setup() if self.provider == "DigitalOcean": # Adjusting docker permissions @@ -883,6 +881,7 @@ def _get_config(self): ), log_server=log_server, kibana_password=kibana_password, + private_key=self._generate_and_activate_key(), ) ) diff --git a/press/press/doctype/proxy_server/proxy_server.py b/press/press/doctype/proxy_server/proxy_server.py index d97b9384184..861e92df07a 100644 --- a/press/press/doctype/proxy_server/proxy_server.py +++ b/press/press/doctype/proxy_server/proxy_server.py @@ -128,6 +128,8 @@ def _setup_server(self): else: kibana_password = None + private_key = self._generate_and_activate_key() + try: ansible = Ansible( playbook="self_hosted_proxy.yml" if getattr(self, "is_self_hosted", False) else "proxy.yml", @@ -147,6 +149,7 @@ def _setup_server(self): "certificate_full_chain": certificate.full_chain, "certificate_intermediate_chain": certificate.intermediate_chain, "press_url": frappe.utils.get_url(), + "private_key": private_key, }, ) play = ansible.run() @@ -154,6 +157,7 @@ def _setup_server(self): if play.status == "Success": self.status = "Active" self.is_server_setup = True + self.is_agent_auth_setup = 1 else: self.status = "Broken" except Exception: @@ -178,10 +182,6 @@ def _install_exporters(self): except Exception: log_error("Exporters Install Exception", server=self.as_dict()) - @frappe.whitelist() - def setup_agent_auth(self): - frappe.enqueue_doc(self.doctype, self.name, "_setup_agent_auth", queue="long", timeout=1200) - @frappe.whitelist() def setup_ssh_proxy(self): if not self.ssh_certificate_authority: diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index a7756276bed..85159c92a35 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -3128,6 +3128,7 @@ def _setup_server(self): certificate = self.get_certificate() log_server, kibana_password = self.get_log_server() agent_sentry_dsn = frappe.db.get_single_value("Press Settings", "agent_sentry_dsn") + private_key = self._generate_and_activate_key() # If database server is set, then define db port under configuration db_port = ( @@ -3151,6 +3152,7 @@ def _setup_server(self): "agent_repository_url": agent_repository_url, "agent_branch": agent_branch, "agent_sentry_dsn": agent_sentry_dsn, + "private_key": private_key, "monitoring_password": self.get_monitoring_password(), "log_server": log_server, "kibana_password": kibana_password, @@ -3171,6 +3173,7 @@ def _setup_server(self): if play.status == "Success": self.status = "Active" self.is_server_setup = True + self.is_agent_auth_setup = 1 if self.provider == "DigitalOcean": # To adjust docker permissions self.reboot() From 6c19b5c6fd187ba09ea15c1c0913de46b2f79d13 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sun, 3 May 2026 06:16:39 +0000 Subject: [PATCH 12/59] fix(agent): Don't verify agent responses --- press/agent.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/press/agent.py b/press/agent.py index 250b84846d6..e912e7c7470 100644 --- a/press/agent.py +++ b/press/agent.py @@ -1037,8 +1037,6 @@ def request(self, method, path, data=None, files=None, agent_job=None, raises=Tr response = self._make_req(method, path, data, files, agent_job_id) json_response = response.json() - self.extract_and_verify_token(response, json_response, method, path) - if raises and response.status_code >= 400: output = "\n\n".join([json_response.get("output", ""), json_response.get("traceback", "")]) if output == "\n\n": @@ -1110,8 +1108,6 @@ def raw_request(self, method, path, data=None, raises=True, timeout=None): response = requests.request(method, url, headers=headers, json=data, timeout=timeout) json_response = response.json() - self.extract_and_verify_token(response, json_response, method, path) - if raises: response.raise_for_status() return json_response From f293457e53b42c5842a7bdd6e6dd39d266cc6e61 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sun, 3 May 2026 11:05:09 +0000 Subject: [PATCH 13/59] feat(api): Verify agent in whitelists which agent requests --- press/agent.py | 18 +++++++----------- press/api/agent_auth.py | 16 ++++++++++++++++ press/api/callbacks.py | 3 +++ press/api/monitoring.py | 5 ++++- press/api/server.py | 9 +++------ 5 files changed, 33 insertions(+), 18 deletions(-) create mode 100644 press/api/agent_auth.py diff --git a/press/agent.py b/press/agent.py index e912e7c7470..13384fc5438 100644 --- a/press/agent.py +++ b/press/agent.py @@ -952,10 +952,10 @@ def _make_req(self, method, path, data, files, agent_job_id): return requests.request(method, url, headers=headers, files=file_objects, verify=verify) return requests.request(method, url, headers=headers, json=data, verify=verify, timeout=(10, 30)) - def _verify_response_token(self, token: dict, payload, method: str, path: str): + def _verify_request_token(self, token: dict, payload, method: str, path: str): try: timestamp = int(token["timestamp"]) - nonce = token["nonce"] + nonce = str(token["nonce"]) signature_b64 = token["signature"] except KeyError as err: raise ValueError("Invalid token structure") from err @@ -978,8 +978,6 @@ def _verify_response_token(self, token: dict, payload, method: str, path: str): public_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(agent.public_key)) - path = f"/{path.lstrip('/')}" - # reconstruct signed message message = json.dumps( { @@ -1011,18 +1009,16 @@ def _verify_response_token(self, token: dict, payload, method: str, path: str): return True - def extract_and_verify_token(self, response, json_response, method, path): - token_str = response.headers.get("X-Agent-Token") - - if not token_str: - raise ValueError("Unsigned response from agent") + def extract_and_verify_token(self, token, json_response, method, path): + if not token: + raise ValueError("Unsigned request from agent") try: - token = json.loads(base64.b64decode(token_str)) + token = json.loads(base64.b64decode(token)) except Exception as err: raise ValueError("Invalid token encoding") from err - self._verify_response_token( + self._verify_request_token( token=token, payload=json_response, method=method, diff --git a/press/api/agent_auth.py b/press/api/agent_auth.py new file mode 100644 index 00000000000..c2d95d3ebb2 --- /dev/null +++ b/press/api/agent_auth.py @@ -0,0 +1,16 @@ +import frappe + +from press.agent import Agent + + +def verify_agent(server: str): + payload = frappe.request.get_json() or {} + path = frappe.request.path + method = frappe.request.method.upper() + agent_token = frappe.request.headers.get("X-Agent-Token") + + if not agent_token: + frappe.throw_permission_error() + + agent = Agent(server) + agent.extract_and_verify_token(agent_token, payload, method, path) diff --git a/press/api/callbacks.py b/press/api/callbacks.py index 1ae4641092f..ba2f30332d9 100644 --- a/press/api/callbacks.py +++ b/press/api/callbacks.py @@ -8,6 +8,7 @@ from frappe.rate_limiter import rate_limit from press.agent import Agent +from press.api.agent_auth import verify_agent from press.press.doctype.agent_job.agent_job import handle_polled_job from press.utils import log_error @@ -113,6 +114,8 @@ def callback(job_id: str | None = None): if not server: frappe.throw("Not permitted", frappe.ValidationError) + verify_agent(server) + job = verify_job_id(server, job_id) if not job: frappe.throw("Invalid Job Id", frappe.ValidationError) diff --git a/press/api/monitoring.py b/press/api/monitoring.py index 8faef742b18..a74ce261937 100644 --- a/press/api/monitoring.py +++ b/press/api/monitoring.py @@ -7,6 +7,7 @@ import frappe from frappe.rate_limiter import rate_limit +from press.api.agent_auth import verify_agent from press.exceptions import AlertRuleNotEnabled from press.press.doctype.monitor_server.monitor_server import get_monitor_server_ips from press.utils import log_error, servers_using_alternative_port_for_communication @@ -159,7 +160,9 @@ def get_targets_method_rate_limit() -> int: @frappe.whitelist(allow_guest=True) @rate_limit(limit=get_targets_method_rate_limit, seconds=MONITORING_ENDPOINT_RATE_LIMIT_WINDOW_SECONDS) -def targets(token: str | None = None): +def targets(server: str, token: str | None = None): + verify_agent(server) + if not token: frappe.throw_permission_error() monitor_token = frappe.db.get_single_value("Press Settings", "monitor_token", cache=True) diff --git a/press/api/server.py b/press/api/server.py index da858c4939c..dbb2de7dd80 100644 --- a/press/api/server.py +++ b/press/api/server.py @@ -13,6 +13,7 @@ from frappe.utils.caching import redis_cache from frappe.utils.password import get_decrypted_password +from press.api.agent_auth import verify_agent from press.api.analytics import auto_timespan_timegrain, get_rounded_boundaries, get_rounded_boundary from press.api.bench import all as all_benches from press.api.site import protected @@ -974,20 +975,16 @@ def rename(name, title): @frappe.whitelist(allow_guest=True) -def benches_are_idle(server: str, access_token: str) -> None: +def benches_are_idle(server: str) -> None: """Shut down the secondary server if all benches are idle. This function is only triggered by secondary servers: https://github.com/frappe/agent/pull/346/files#diff-7355d9c50cadfa3f4c74fc77a4ad8ab08e4da8f6c3326bbf9b0de0f00a0aa0daR87-R93 """ - from passlib.hash import pbkdf2_sha256 as pbkdf2 - server_doc = frappe.get_cached_doc("Server", server) - agent_password = server_doc.get_password("agent_password") current_user = frappe.session.user - if not pbkdf2.verify(agent_password, access_token): - return + verify_agent(server) primary_server, is_server_scaled_up = frappe.db.get_value( "Server", {"secondary_server": server}, ["name", "scaled_up"] From f5f488235ed12675ebd187874a1adf62f1da0df9 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Tue, 5 May 2026 09:18:36 +0000 Subject: [PATCH 14/59] refactor(agent-job): Add agent type annotation to poll_random_jobs --- press/press/doctype/agent_job/agent_job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/press/press/doctype/agent_job/agent_job.py b/press/press/doctype/agent_job/agent_job.py index 6032a9748a8..cff928ecec4 100644 --- a/press/press/doctype/agent_job/agent_job.py +++ b/press/press/doctype/agent_job/agent_job.py @@ -447,7 +447,7 @@ def publish_update(job): @timer -def poll_random_jobs(agent, pending_ids): +def poll_random_jobs(agent: Agent, pending_ids): random_pending_ids = random.sample(pending_ids, k=min(100, len(pending_ids))) return agent.get_jobs_status(random_pending_ids) From 87e76f9a0fd69b7ae5e1b18b58ef039e1b017b16 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Fri, 8 May 2026 18:49:00 +0000 Subject: [PATCH 15/59] refactor(agent): Send agent signed long lived token --- press/agent.py | 69 ++++--------- press/api/agent_auth.py | 5 +- .../roles/setup_agent_auth/tasks/main.yml | 6 +- press/press/doctype/agent_auth/__init__.py | 0 press/press/doctype/agent_auth/agent_auth.js | 8 ++ .../press/doctype/agent_auth/agent_auth.json | 94 ++++++++++++++++++ press/press/doctype/agent_auth/agent_auth.py | 25 +++++ .../doctype/agent_auth/test_agent_auth.py | 29 ++++++ .../analytics_server/analytics_server.js | 6 ++ .../database_server/database_server.json | 22 +---- .../database_server/database_server.py | 2 - press/press/doctype/log_server/log_server.js | 6 ++ .../doctype/monitor_server/monitor_server.js | 6 ++ press/press/doctype/nat_server/nat_server.js | 6 ++ press/press/doctype/nfs_server/nfs_server.js | 6 ++ .../doctype/proxy_server/proxy_server.json | 19 +--- .../doctype/proxy_server/proxy_server.py | 2 - .../registry_server/registry_server.js | 6 ++ press/press/doctype/server/server.json | 17 +--- press/press/doctype/server/server.py | 97 ++++++++++++++++--- .../doctype/trace_server/trace_server.js | 6 ++ 21 files changed, 308 insertions(+), 129 deletions(-) create mode 100644 press/press/doctype/agent_auth/__init__.py create mode 100644 press/press/doctype/agent_auth/agent_auth.js create mode 100644 press/press/doctype/agent_auth/agent_auth.json create mode 100644 press/press/doctype/agent_auth/agent_auth.py create mode 100644 press/press/doctype/agent_auth/test_agent_auth.py diff --git a/press/agent.py b/press/agent.py index 13384fc5438..b2e77f03951 100644 --- a/press/agent.py +++ b/press/agent.py @@ -29,6 +29,7 @@ if TYPE_CHECKING: from io import BufferedReader + from press.press.doctype.agent_auth.agent_auth import AgentAuth from press.press.doctype.agent_job.agent_job import AgentJob from press.press.doctype.app_patch.app_patch import AgentPatchConfig, AppPatch from press.press.doctype.bench.bench import Bench @@ -952,64 +953,39 @@ def _make_req(self, method, path, data, files, agent_job_id): return requests.request(method, url, headers=headers, files=file_objects, verify=verify) return requests.request(method, url, headers=headers, json=data, verify=verify, timeout=(10, 30)) - def _verify_request_token(self, token: dict, payload, method: str, path: str): - try: - timestamp = int(token["timestamp"]) - nonce = str(token["nonce"]) - signature_b64 = token["signature"] - except KeyError as err: - raise ValueError("Invalid token structure") from err + def get_agent_public_key(self): + key = f"{self.server}_agent_public_key" - # timestamp validation - now = int(time.time()) - WINDOW = 30 + public_key = frappe.cache().get_value(key) - if timestamp > now + 5: - raise ValueError("Token from future") + if not public_key: + agent_auth: AgentAuth = frappe.get_doc("Agent Auth", self.server) + public_key = agent_auth.public_key + frappe.cache().set_value(key, public_key, expires_in_sec=3600) - if now - timestamp > WINDOW: - raise ValueError("Token expired") + return public_key - # fetch public key - agent = frappe.get_doc("Server", self.server) + def _verify_request_token(self, token: str): + payload_b64, signature_b64 = token.split(".") - if not agent.public_key: - raise ValueError("No public key registered") + payload_bytes = base64.urlsafe_b64decode(payload_b64 + "==") + signature = base64.urlsafe_b64decode(signature_b64 + "==") - public_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(agent.public_key)) + public_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(self.get_agent_public_key())) - # reconstruct signed message - message = json.dumps( - { - "method": method, - "path": path, - "timestamp": timestamp, - "nonce": nonce, - "payload": payload, - }, - separators=(",", ":"), - sort_keys=True, - ).encode() + public_key.verify(signature, payload_bytes) - signature = base64.b64decode(signature_b64) + payload = json.loads(payload_bytes) - # verify signature - try: - public_key.verify(signature, message) - except Exception as err: - raise ValueError("Invalid signature") from err - - # replay protection - cache_key = f"nonce:{self.server}:{nonce}" - - if frappe.cache().get_value(cache_key): - raise ValueError("Replay attack detected") + if payload["exp"] < time.time(): + raise ValueError("Token expired") - frappe.cache().set_value(cache_key, 1, expires_in_sec=60) + if payload["server"] != self.server: + raise ValueError("Invalid server") return True - def extract_and_verify_token(self, token, json_response, method, path): + def extract_and_verify_token(self, token): if not token: raise ValueError("Unsigned request from agent") @@ -1020,9 +996,6 @@ def extract_and_verify_token(self, token, json_response, method, path): self._verify_request_token( token=token, - payload=json_response, - method=method, - path=path, ) def request(self, method, path, data=None, files=None, agent_job=None, raises=True): diff --git a/press/api/agent_auth.py b/press/api/agent_auth.py index c2d95d3ebb2..c99df843a24 100644 --- a/press/api/agent_auth.py +++ b/press/api/agent_auth.py @@ -4,13 +4,10 @@ def verify_agent(server: str): - payload = frappe.request.get_json() or {} - path = frappe.request.path - method = frappe.request.method.upper() agent_token = frappe.request.headers.get("X-Agent-Token") if not agent_token: frappe.throw_permission_error() agent = Agent(server) - agent.extract_and_verify_token(agent_token, payload, method, path) + agent.extract_and_verify_token(agent_token) diff --git a/press/playbooks/roles/setup_agent_auth/tasks/main.yml b/press/playbooks/roles/setup_agent_auth/tasks/main.yml index 37f89902104..bd914a0f743 100644 --- a/press/playbooks/roles/setup_agent_auth/tasks/main.yml +++ b/press/playbooks/roles/setup_agent_auth/tasks/main.yml @@ -1,6 +1,6 @@ --- -- name: Write private key +- name: Write agent token copy: - content: "{{ private_key }}" - dest: "/home/frappe/agent/private.key" + content: "{{ agent_token }}" + dest: "/home/frappe/agent/agent.token" mode: '0600' diff --git a/press/press/doctype/agent_auth/__init__.py b/press/press/doctype/agent_auth/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/press/press/doctype/agent_auth/agent_auth.js b/press/press/doctype/agent_auth/agent_auth.js new file mode 100644 index 00000000000..1310b1c5dde --- /dev/null +++ b/press/press/doctype/agent_auth/agent_auth.js @@ -0,0 +1,8 @@ +// Copyright (c) 2026, Frappe and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Agent Auth", { +// refresh(frm) { + +// }, +// }); diff --git a/press/press/doctype/agent_auth/agent_auth.json b/press/press/doctype/agent_auth/agent_auth.json new file mode 100644 index 00000000000..00caaf098a9 --- /dev/null +++ b/press/press/doctype/agent_auth/agent_auth.json @@ -0,0 +1,94 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:server", + "creation": "2026-05-08 20:05:51.848511", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "section_break_loa1", + "server", + "server_type", + "public_key", + "regenerate_public_key", + "is_agent_auth_setup", + "column_break_dlmn", + "expires_in" + ], + "fields": [ + { + "fieldname": "section_break_loa1", + "fieldtype": "Section Break", + "label": "Agent Auth" + }, + { + "fieldname": "server_type", + "fieldtype": "Data", + "label": "Server Type", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_agent_auth_setup", + "fieldtype": "Check", + "label": "Is Agent Auth Setup", + "read_only": 1 + }, + { + "fieldname": "expires_in", + "fieldtype": "Datetime", + "label": "Expires In", + "read_only": 1 + }, + { + "fieldname": "public_key", + "fieldtype": "Data", + "label": "Public Key", + "read_only": 1 + }, + { + "fieldname": "column_break_dlmn", + "fieldtype": "Column Break" + }, + { + "fieldname": "server", + "fieldtype": "Data", + "label": "Server", + "read_only": 1, + "unique": 1 + }, + { + "fieldname": "regenerate_public_key", + "fieldtype": "Data", + "label": "Regenerate Public Key", + "read_only": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-05-08 23:59:06.513841", + "modified_by": "Administrator", + "module": "Press", + "name": "Agent Auth", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/press/press/doctype/agent_auth/agent_auth.py b/press/press/doctype/agent_auth/agent_auth.py new file mode 100644 index 00000000000..70ba57b61bc --- /dev/null +++ b/press/press/doctype/agent_auth/agent_auth.py @@ -0,0 +1,25 @@ +# Copyright (c) 2026, Frappe and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class AgentAuth(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + expires_in: DF.Datetime | None + is_agent_auth_setup: DF.Check + public_key: DF.Data | None + regenerate_public_key: DF.Data | None + server: DF.Data | None + server_type: DF.Data | None + # end: auto-generated types + + pass diff --git a/press/press/doctype/agent_auth/test_agent_auth.py b/press/press/doctype/agent_auth/test_agent_auth.py new file mode 100644 index 00000000000..b8d32e73122 --- /dev/null +++ b/press/press/doctype/agent_auth/test_agent_auth.py @@ -0,0 +1,29 @@ +# Copyright (c) 2026, Frappe and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class UnitTestAgentAuth(UnitTestCase): + """ + Unit tests for AgentAuth. + Use this class for testing individual functions and methods. + """ + + pass + + +class IntegrationTestAgentAuth(IntegrationTestCase): + """ + Integration tests for AgentAuth. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/press/press/doctype/analytics_server/analytics_server.js b/press/press/doctype/analytics_server/analytics_server.js index c8bc3afa7ed..cfcb048e17f 100644 --- a/press/press/doctype/analytics_server/analytics_server.js +++ b/press/press/doctype/analytics_server/analytics_server.js @@ -10,6 +10,12 @@ frappe.ui.form.on('Analytics Server', { [__('Update Agent'), 'update_agent', true, frm.doc.is_server_setup], [__('Prepare Server'), 'prepare_server', true, !frm.doc.is_server_setup], [__('Setup Server'), 'setup_server', true, !frm.doc.is_server_setup], + [ + __('Setup Agent Auth'), + 'setup_agent_auth', + false, + frm.doc.is_server_setup, + ], [ __('Fetch Keys'), 'fetch_keys', diff --git a/press/press/doctype/database_server/database_server.json b/press/press/doctype/database_server/database_server.json index 60d0fbd602c..db097c2d978 100644 --- a/press/press/doctype/database_server/database_server.json +++ b/press/press/doctype/database_server/database_server.json @@ -47,9 +47,6 @@ "private_vlan_id", "agent_section", "agent_password", - "public_key", - "column_break_zpnv", - "is_agent_auth_setup", "mariadb_section", "self_hosted_mariadb_server", "mariadb_root_password", @@ -790,23 +787,6 @@ "fieldtype": "Link", "label": "NAT Server", "options": "NAT Server" - }, - { - "fieldname": "column_break_zpnv", - "fieldtype": "Column Break" - }, - { - "default": "0", - "fieldname": "is_agent_auth_setup", - "fieldtype": "Check", - "label": "Is Agent Auth Setup", - "read_only": 1 - }, - { - "fieldname": "public_key", - "fieldtype": "Data", - "label": "Public Key", - "read_only": 1 } ], "grid_page_length": 50, @@ -823,7 +803,7 @@ "link_fieldname": "database_server" } ], - "modified": "2026-05-02 20:52:27.931597", + "modified": "2026-05-08 17:30:02.812385", "modified_by": "Administrator", "module": "Press", "name": "Database Server", diff --git a/press/press/doctype/database_server/database_server.py b/press/press/doctype/database_server/database_server.py index 8f7d7dd4833..fc065a5f700 100644 --- a/press/press/doctype/database_server/database_server.py +++ b/press/press/doctype/database_server/database_server.py @@ -77,7 +77,6 @@ class DatabaseServer(BaseServer): hostname: DF.Data hostname_abbreviation: DF.Data | None ip: DF.Data | None - is_agent_auth_setup: DF.Check is_binlog_indexer_running: DF.Check is_for_recovery: DF.Check is_monitoring_disabled: DF.Check @@ -109,7 +108,6 @@ class DatabaseServer(BaseServer): "Generic", "Scaleway", "AWS EC2", "OCI", "Hetzner", "Vodacom", "DigitalOcean", "Frappe Compute" ] public: DF.Check - public_key: DF.Data | None ram: DF.Float root_public_key: DF.Code | None self_hosted_mariadb_server: DF.Data | None diff --git a/press/press/doctype/log_server/log_server.js b/press/press/doctype/log_server/log_server.js index 774ad92e8ca..0bdbd313909 100644 --- a/press/press/doctype/log_server/log_server.js +++ b/press/press/doctype/log_server/log_server.js @@ -24,6 +24,12 @@ frappe.ui.form.on('Log Server', { false, frm.doc.is_server_setup, ], + [ + __('Setup Agent Auth'), + 'setup_agent_auth', + false, + frm.doc.is_server_setup, + ], [__('Update TLS Certificate'), 'update_tls_certificate', true], ].forEach(([label, method, confirm, condition]) => { if (typeof condition === 'undefined' || condition) { diff --git a/press/press/doctype/monitor_server/monitor_server.js b/press/press/doctype/monitor_server/monitor_server.js index 011288075d4..0e7f1613ad2 100644 --- a/press/press/doctype/monitor_server/monitor_server.js +++ b/press/press/doctype/monitor_server/monitor_server.js @@ -30,6 +30,12 @@ frappe.ui.form.on('Monitor Server', { false, frm.doc.is_server_setup, ], + [ + __('Setup Agent Auth'), + 'setup_agent_auth', + false, + frm.doc.is_server_setup, + ], [__('Update TLS Certificate'), 'update_tls_certificate', true], ].forEach(([label, method, confirm, condition]) => { if (typeof condition === 'undefined' || condition) { diff --git a/press/press/doctype/nat_server/nat_server.js b/press/press/doctype/nat_server/nat_server.js index 221910ff41c..ab3263d7de8 100644 --- a/press/press/doctype/nat_server/nat_server.js +++ b/press/press/doctype/nat_server/nat_server.js @@ -16,6 +16,12 @@ frappe.ui.form.on('NAT Server', { true, frm.doc.is_server_setup, ], // added temporarily for setting up nginx & monitoring config + [ + __('Setup Agent Auth'), + 'setup_agent_auth', + false, + frm.doc.is_server_setup, + ], ].forEach(([label, method, confirm, condition]) => { if (typeof condition === 'undefined' || condition) { frm.add_custom_button( diff --git a/press/press/doctype/nfs_server/nfs_server.js b/press/press/doctype/nfs_server/nfs_server.js index 2a624a541ab..1424e709886 100644 --- a/press/press/doctype/nfs_server/nfs_server.js +++ b/press/press/doctype/nfs_server/nfs_server.js @@ -11,6 +11,12 @@ frappe.ui.form.on('NFS Server', { !frm.doc.is_server_prepared, ], [__('Setup Server'), 'setup_server', true, !frm.doc.is_server_setup], + [ + __('Setup Agent Auth'), + 'setup_agent_auth', + false, + frm.doc.is_server_setup, + ], ].forEach(([label, method, confirm, condition]) => { if (typeof condition === 'undefined' || condition) { frm.add_custom_button( diff --git a/press/press/doctype/proxy_server/proxy_server.json b/press/press/doctype/proxy_server/proxy_server.json index 21c4220488f..e32da49bfa4 100644 --- a/press/press/doctype/proxy_server/proxy_server.json +++ b/press/press/doctype/proxy_server/proxy_server.json @@ -36,11 +36,9 @@ "private_vlan_id", "agent_section", "agent_password", - "public_key", "column_break_mznm", "disable_agent_job_auto_retry", "use_as_proxy_for_agent_and_metrics", - "is_agent_auth_setup", "replica_section", "is_primary", "primary", @@ -497,23 +495,10 @@ "fieldname": "use_as_proxy_for_agent_and_metrics", "fieldtype": "Check", "label": "Use as Proxy for Agent and Metrics" - }, - { - "fieldname": "public_key", - "fieldtype": "Data", - "label": "Public Key", - "read_only": 1 - }, - { - "default": "0", - "fieldname": "is_agent_auth_setup", - "fieldtype": "Check", - "label": "Is Agent Auth Setup", - "read_only": 1 } ], "links": [], - "modified": "2026-05-02 18:34:57.231059", + "modified": "2026-05-08 17:29:31.812623", "modified_by": "Administrator", "module": "Press", "name": "Proxy Server", @@ -537,4 +522,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/press/press/doctype/proxy_server/proxy_server.py b/press/press/doctype/proxy_server/proxy_server.py index 861e92df07a..63b840b43b8 100644 --- a/press/press/doctype/proxy_server/proxy_server.py +++ b/press/press/doctype/proxy_server/proxy_server.py @@ -41,7 +41,6 @@ class ProxyServer(BaseServer): hostname: DF.Data hostname_abbreviation: DF.Data | None ip: DF.Data | None - is_agent_auth_setup: DF.Check is_primary: DF.Check is_proxysql_setup: DF.Check is_replication_setup: DF.Check @@ -63,7 +62,6 @@ class ProxyServer(BaseServer): proxysql_admin_password: DF.Password | None proxysql_monitor_password: DF.Password | None public: DF.Check - public_key: DF.Data | None root_public_key: DF.Code | None self_hosted_server_domain: DF.Data | None ssh_certificate_authority: DF.Link | None diff --git a/press/press/doctype/registry_server/registry_server.js b/press/press/doctype/registry_server/registry_server.js index dcaff487fce..903dc9bae66 100644 --- a/press/press/doctype/registry_server/registry_server.js +++ b/press/press/doctype/registry_server/registry_server.js @@ -8,6 +8,12 @@ frappe.ui.form.on('Registry Server', { [__('Ping Ansible Unprepared'), 'ping_ansible_unprepared', true], [__('Prepare Server'), 'prepare_server', true, !frm.doc.is_server_setup], [__('Setup Server'), 'setup_server', true, !frm.doc.is_server_setup], + [ + __('Setup Agent Auth'), + 'setup_agent_auth', + false, + frm.doc.is_server_setup, + ], [ __('Rewrite Config'), 'rewrite_config', diff --git a/press/press/doctype/server/server.json b/press/press/doctype/server/server.json index 9c3be778b54..2fe9cb138d7 100644 --- a/press/press/doctype/server/server.json +++ b/press/press/doctype/server/server.json @@ -52,10 +52,8 @@ "private_vlan_id", "agent_section", "agent_password", - "public_key", "column_break_pdbx", "disable_agent_job_auto_retry", - "is_agent_auth_setup", "reverse_proxy_section", "proxy_server", "column_break_12", @@ -757,19 +755,6 @@ "fieldname": "enable_on_prem_failover_support", "fieldtype": "Check", "label": "Enable On-Prem Failover Support" - }, - { - "fieldname": "public_key", - "fieldtype": "Data", - "label": "Public Key", - "read_only": 1 - }, - { - "default": "0", - "fieldname": "is_agent_auth_setup", - "fieldtype": "Check", - "label": "Is Agent Auth Setup", - "read_only": 1 } ], "links": [ @@ -782,7 +767,7 @@ "link_fieldname": "app_server" } ], - "modified": "2026-05-02 16:31:13.693849", + "modified": "2026-05-08 17:29:46.580115", "modified_by": "Administrator", "module": "Press", "name": "Server", diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index 85159c92a35..e74f4330d5b 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -18,6 +18,7 @@ import frappe import requests import semantic_version +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from frappe import _ from frappe.core.utils import find, find_all from frappe.installer import subprocess @@ -49,6 +50,7 @@ if typing.TYPE_CHECKING: from press.infrastructure.doctype.arm_build_record.arm_build_record import ARMBuildRecord + from press.press.doctype.agent_auth.agent_auth import AgentAuth from press.press.doctype.agent_job.agent_job import AgentJob from press.press.doctype.ansible_play.ansible_play import AnsiblePlay from press.press.doctype.auto_scale_record.auto_scale_record import AutoScaleRecord @@ -1216,6 +1218,27 @@ def _create_initial_plan_change(self, plan): } ).insert(ignore_permissions=True) + @cached_property + def agent_auth(self) -> AgentAuth: + name = frappe.db.get_value( + "Agent Auth", + { + "server": self.name, + "server_type": self.doctype, + }, + ) + + if name: + return frappe.get_doc("Agent Auth", name) + + return frappe.get_doc( + { + "doctype": "Agent Auth", + "server": self.name, + "server_type": self.doctype, + } + ).insert(ignore_permissions=True) + @property def subscription(self): name = frappe.db.get_value( @@ -1241,6 +1264,10 @@ def add_on_storage_subscription(self): ) return frappe.get_doc("Subscription", name) if name else None + @frappe.whitelist() + def setup_agent_auth(self): + frappe.enqueue_doc(self.doctype, self.name, "_setup_agent_auth", queue="long", timeout=1200) + @frappe.whitelist() def rename_server(self): self.status = "Installing" @@ -1900,20 +1927,60 @@ def _get_dependency_version(self, candidate: str, dependency: str) -> str: "version", ) - def _generate_and_activate_key(self, regenerate: bool = False) -> str | None: + def sign_agent_token(self, private_key: str): + if not private_key: + return None + + auth = self.agent_auth + + expires_in = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=90)).replace( + tzinfo=None + ) + + payload = { + "server": self.name, + "exp": int(expires_in.timestamp()), # 3 month + } + + payload_bytes = json.dumps( + payload, + separators=(",", ":"), + sort_keys=True, + ).encode() + + private_key_obj = Ed25519PrivateKey.from_private_bytes(base64.b64decode(private_key)) + + signature = private_key_obj.sign(payload_bytes) + + token = ( + base64.urlsafe_b64encode(payload_bytes).decode().rstrip("=") + + "." + + base64.urlsafe_b64encode(signature).decode().rstrip("=") + ) + + auth.agent_token = token + auth.expires_in = expires_in + + return token + + def _generate_and_activate_key(self) -> str | None: from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ed25519 - if self.public_key and self.is_agent_auth_setup and not regenerate: + auth = self.agent_auth + + if auth.public_key and auth.is_agent_auth_setup: return None key = ed25519.Ed25519PrivateKey.generate() - private_key_str = key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, + private_key_bytes = key.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, encryption_algorithm=serialization.NoEncryption(), - ).decode() + ) + + private_key_str = base64.b64encode(private_key_bytes).decode() public_key_bytes = key.public_key().public_bytes( encoding=serialization.Encoding.Raw, @@ -1922,28 +1989,28 @@ def _generate_and_activate_key(self, regenerate: bool = False) -> str | None: public_key_b64 = base64.b64encode(public_key_bytes).decode() - self.public_key = public_key_b64 - self.save() + auth.public_key = public_key_b64 return private_key_str - @frappe.whitelist() - def setup_agent_auth(self): - frappe.enqueue_doc(self.doctype, self.name, "_setup_agent_auth", queue="long", timeout=1200) - def _setup_agent_auth(self): try: + auth = self.agent_auth + private_key = self._generate_and_activate_key() + agent_token = self.sign_agent_token(private_key) ansible = Ansible( playbook="setup_agent_auth.yml", server=self, user=self._ssh_user(), port=self._ssh_port(), - variables={"private_key": private_key}, + variables={"agent_token": agent_token}, ) ansible.run() - self.is_agent_auth_setup = 1 + auth.is_agent_auth_setup = 1 + + auth.save(ignore_permissions=True) except Exception: log_error("Agent Auth Setup Exception", server=self.as_dict()) self.save() @@ -2740,7 +2807,6 @@ class Server(BaseServer): ignore_incidents_till: DF.Datetime | None ip: DF.Data | None ipv6: DF.Data | None - is_agent_auth_setup: DF.Check is_for_recovery: DF.Check is_managed_database: DF.Check is_monitoring_disabled: DF.Check @@ -2774,7 +2840,6 @@ class Server(BaseServer): ] proxy_server: DF.Link | None public: DF.Check - public_key: DF.Data | None ram: DF.Float root_public_key: DF.Code | None scaled_up: DF.Check diff --git a/press/press/doctype/trace_server/trace_server.js b/press/press/doctype/trace_server/trace_server.js index 0023410b338..935a6b53604 100644 --- a/press/press/doctype/trace_server/trace_server.js +++ b/press/press/doctype/trace_server/trace_server.js @@ -30,6 +30,12 @@ frappe.ui.form.on('Trace Server', { false, frm.doc.is_server_setup, ], + [ + __('Setup Agent Auth'), + 'setup_agent_auth', + false, + frm.doc.is_server_setup, + ], ].forEach(([label, method, confirm, condition]) => { if (typeof condition === 'undefined' || condition) { frm.add_custom_button( From 3963c3382eaff074e9c7906a7fb351a1cc8359dd Mon Sep 17 00:00:00 2001 From: 20vikash Date: Fri, 8 May 2026 21:29:36 +0000 Subject: [PATCH 16/59] feat(agent): Schedule to check for token regeneration daily --- press/agent.py | 59 ++++++++++++++++-- press/hooks.py | 1 + press/press/doctype/agent_auth/agent_auth.py | 63 +++++++++++++++++++- press/press/doctype/server/server.py | 1 - 4 files changed, 116 insertions(+), 8 deletions(-) diff --git a/press/agent.py b/press/agent.py index b2e77f03951..416b0203732 100644 --- a/press/agent.py +++ b/press/agent.py @@ -959,25 +959,74 @@ def get_agent_public_key(self): public_key = frappe.cache().get_value(key) if not public_key: - agent_auth: AgentAuth = frappe.get_doc("Agent Auth", self.server) + agent_auth: AgentAuth = frappe.get_doc( + "Agent Auth", + self.server, + ) + public_key = agent_auth.public_key - frappe.cache().set_value(key, public_key, expires_in_sec=3600) + + frappe.cache().set_value( + key, + public_key, + expires_in_sec=3600, + ) return public_key + def get_regenerate_public_key(self): + key = f"{self.server}_regenerate_public_key" + + regenerate_key = frappe.cache().get_value(key) + if not regenerate_key: + agent_auth: AgentAuth = frappe.get_doc( + "Agent Auth", + self.server, + ) + + if agent_auth.regenerate_public_key: + agent_auth.regenerate_public_key = None + agent_auth.save(ignore_permissions=True) + + return None + + return regenerate_key + def _verify_request_token(self, token: str): + from cryptography.exceptions import InvalidSignature + payload_b64, signature_b64 = token.split(".") payload_bytes = base64.urlsafe_b64decode(payload_b64 + "==") signature = base64.urlsafe_b64decode(signature_b64 + "==") - public_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(self.get_agent_public_key())) + public_keys = [self.get_agent_public_key()] + + regenerate_key = self.get_regenerate_public_key() + + if regenerate_key: + public_keys.append(regenerate_key) + + verified = False + + for key in public_keys: + try: + public_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(key)) + + public_key.verify(signature, payload_bytes) + + verified = True + break + + except InvalidSignature: + pass - public_key.verify(signature, payload_bytes) + if not verified: + raise ValueError("Invalid token signature") payload = json.loads(payload_bytes) - if payload["exp"] < time.time(): + if payload["exp"] < (time.time() - 60): raise ValueError("Token expired") if payload["server"] != self.server: diff --git a/press/hooks.py b/press/hooks.py index dacad23467c..10e6a550302 100644 --- a/press/hooks.py +++ b/press/hooks.py @@ -197,6 +197,7 @@ scheduler_events = { "weekly_long": ["press.press.doctype.marketplace_app.events.auto_review_for_missing_steps"], "daily": [ + "press.press.doctype.agent_auth.agent_auth.regenerate_token", "press.experimental.doctype.referral_bonus.referral_bonus.credit_referral_bonuses", "press.press.doctype.log_counter.log_counter.record_counts", "press.press.doctype.incident.incident.notify_ignored_servers", diff --git a/press/press/doctype/agent_auth/agent_auth.py b/press/press/doctype/agent_auth/agent_auth.py index 70ba57b61bc..bed1f2cfa36 100644 --- a/press/press/doctype/agent_auth/agent_auth.py +++ b/press/press/doctype/agent_auth/agent_auth.py @@ -1,9 +1,15 @@ # Copyright (c) 2026, Frappe and contributors # For license information, please see license.txt -# import frappe +import datetime +from typing import TYPE_CHECKING + +import frappe from frappe.model.document import Document +if TYPE_CHECKING: + from press.press.doctype.server.server import BaseServer + class AgentAuth(Document): # begin: auto-generated types @@ -22,4 +28,57 @@ class AgentAuth(Document): server_type: DF.Data | None # end: auto-generated types - pass + def _regenerate_token(self): + if not self.is_agent_auth_setup: + return + + # prevent concurrent regeneration + lock_key = f"agent_auth_regeneration:{self.server}" + + with frappe.cache().lock(lock_key, timeout=600): + # already rotating + if self.regenerate_public_key: + return + + # preserve current public key temporarily + self.regenerate_public_key = self.public_key + + self.save(ignore_permissions=True) + + # cache old key for dual verification window + frappe.cache().set_value( + f"{self.server}_regenerate_public_key", + self.regenerate_public_key, + expires_in_sec=300, # 5 min + ) + + server: BaseServer = frappe.get_doc( + self.server_type, + self.server, + ) + + server._setup_agent_auth() + + +def regenerate_token(): + seven_days_from_now = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=7)).replace( + tzinfo=None + ) + + agent_auths = frappe.get_all( + "Agent Auth", + filters={ + "is_agent_auth_setup": 1, + "expires_in": ["<=", seven_days_from_now], + }, + fields=["name"], + ) + + for auth in agent_auths: + frappe.enqueue_doc( + "Agent Auth", + auth.name, + "_regenerate_token", + queue="long", + timeout=1200, + ) diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index e74f4330d5b..9ad01629fac 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -1958,7 +1958,6 @@ def sign_agent_token(self, private_key: str): + base64.urlsafe_b64encode(signature).decode().rstrip("=") ) - auth.agent_token = token auth.expires_in = expires_in return token From df10f2689aff02c5380aaa41445c5c6b35167a68 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sat, 9 May 2026 09:49:18 +0000 Subject: [PATCH 17/59] fix(agent-auth): Handle token regeneration edge cases --- press/agent.py | 17 +++++++---------- press/press/doctype/agent_auth/agent_auth.py | 6 +++++- press/press/doctype/server/server.py | 6 ++++++ 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/press/agent.py b/press/agent.py index 416b0203732..acb5035835c 100644 --- a/press/agent.py +++ b/press/agent.py @@ -966,11 +966,13 @@ def get_agent_public_key(self): public_key = agent_auth.public_key - frappe.cache().set_value( - key, - public_key, - expires_in_sec=3600, - ) + if not frappe.cache().get_value(f"{self.server}_regenerate_public_key"): + # Don't set cache while regenerating. Old public key may get cached again. + frappe.cache().set_value( + key, + public_key, + expires_in_sec=3600, + ) return public_key @@ -1038,11 +1040,6 @@ def extract_and_verify_token(self, token): if not token: raise ValueError("Unsigned request from agent") - try: - token = json.loads(base64.b64decode(token)) - except Exception as err: - raise ValueError("Invalid token encoding") from err - self._verify_request_token( token=token, ) diff --git a/press/press/doctype/agent_auth/agent_auth.py b/press/press/doctype/agent_auth/agent_auth.py index bed1f2cfa36..fa351ee0013 100644 --- a/press/press/doctype/agent_auth/agent_auth.py +++ b/press/press/doctype/agent_auth/agent_auth.py @@ -43,13 +43,17 @@ def _regenerate_token(self): # preserve current public key temporarily self.regenerate_public_key = self.public_key + # Clear out current data + self.is_agent_auth_setup = 0 + self.expires_in = None + self.save(ignore_permissions=True) # cache old key for dual verification window frappe.cache().set_value( f"{self.server}_regenerate_public_key", self.regenerate_public_key, - expires_in_sec=300, # 5 min + expires_in_sec=60, # 1 min ) server: BaseServer = frappe.get_doc( diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index 9ad01629fac..17278238ab9 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -684,6 +684,11 @@ def get_agent_repository_branch(self): settings = frappe.get_single("Press Settings") return settings.branch or "master" + @frappe.whitelist() + def regenerate_token(self): + agent_auth: AgentAuth = frappe.get_doc("Agent Auth", self.name) + agent_auth._regenerate_token() + @frappe.whitelist() def ping_agent(self): agent = Agent(self.name, self.doctype) @@ -1988,6 +1993,7 @@ def _generate_and_activate_key(self) -> str | None: public_key_b64 = base64.b64encode(public_key_bytes).decode() + frappe.cache.delete_key(f"{auth.server}_agent_public_key") auth.public_key = public_key_b64 return private_key_str From 6d72f951c3d0da2594e69a7c470da80cb1e6d22f Mon Sep 17 00:00:00 2001 From: 20vikash Date: Tue, 12 May 2026 10:27:41 +0000 Subject: [PATCH 18/59] feat(callback): Update status given by agent --- press/api/callbacks.py | 27 ++++++++++++++++++++++ press/press/doctype/agent_job/agent_job.py | 4 ++++ 2 files changed, 31 insertions(+) diff --git a/press/api/callbacks.py b/press/api/callbacks.py index ba2f30332d9..90f0e24f7e5 100644 --- a/press/api/callbacks.py +++ b/press/api/callbacks.py @@ -3,6 +3,7 @@ from __future__ import annotations import ipaddress +import json import frappe from frappe.rate_limiter import rate_limit @@ -121,3 +122,29 @@ def callback(job_id: str | None = None): frappe.throw("Invalid Job Id", frappe.ValidationError) frappe.enqueue(handle_job_updates, server=server, job_identifier=job_id) + + +@frappe.whitelist(allow_guest=True) +@rate_limit(limit=10, seconds=60) +def update_job(job, server): + if not job: + return + + verify_agent(server) + + job = json.loads(job) + + job_doc = frappe.get_value( + "Agent Job", + fieldname=[ + "name", + "job_id", + "status", + "callback_failure_count", + "job_type", + ], + filters={"job_id": job["id"]}, + as_dict=True, + ) + + handle_polled_job(polled_job=job, job=job_doc) diff --git a/press/press/doctype/agent_job/agent_job.py b/press/press/doctype/agent_job/agent_job.py index cff928ecec4..1f0e566e86d 100644 --- a/press/press/doctype/agent_job/agent_job.py +++ b/press/press/doctype/agent_job/agent_job.py @@ -425,6 +425,10 @@ def publish_update(job): message = job_detail(job) frappe.publish_realtime(event="agent_job_update", doctype="Agent Job", docname=job, message=message) + frappe.publish_realtime( + event="doc_update", doctype="Agent Job", docname=job, message={"doctype": "Agent Job", "name": job} + ) + # publish event for agent job list to update in dashboard # we are doing this since process agent job doesn't emit list_update for job due to set_value frappe.publish_realtime(event="list_update", message={"doctype": "Agent Job", "name": job}) From 231704e186a693d5c555cf51397959c21f5b638e Mon Sep 17 00:00:00 2001 From: 20vikash Date: Tue, 12 May 2026 11:13:31 +0000 Subject: [PATCH 19/59] feat(agent-job): Publish realtime for each step --- press/press/doctype/agent_job/agent_job.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/press/press/doctype/agent_job/agent_job.py b/press/press/doctype/agent_job/agent_job.py index 1f0e566e86d..4dac20b5634 100644 --- a/press/press/doctype/agent_job/agent_job.py +++ b/press/press/doctype/agent_job/agent_job.py @@ -426,7 +426,11 @@ def publish_update(job): frappe.publish_realtime(event="agent_job_update", doctype="Agent Job", docname=job, message=message) frappe.publish_realtime( - event="doc_update", doctype="Agent Job", docname=job, message={"doctype": "Agent Job", "name": job} + event="doc_update", + doctype="Agent Job", + docname=job, + message={"doctype": "Agent Job", "name": job}, + after_commit=True, ) # publish event for agent job list to update in dashboard @@ -779,6 +783,19 @@ def update_step(step_name, step): }, ) + frappe.publish_realtime( + event="doc_update", + doctype="Agent Job Step", + docname=step_name, + message={"doctype": "Agent Job Step", "name": step_name}, + after_commit=True, + ) + + # Force the Agent Job Step list view to refresh + frappe.publish_realtime( + event="list_update", message={"doctype": "Agent Job Step", "name": step_name}, after_commit=True + ) + def skip_pending_steps(job_name): frappe.db.sql( From 956d8a430160c836292beee809263bc4abe4401f Mon Sep 17 00:00:00 2001 From: 20vikash Date: Tue, 12 May 2026 11:26:12 +0000 Subject: [PATCH 20/59] refactor(agent-job): Move agent step publish into publish_update --- press/hooks.py | 2 +- press/press/doctype/agent_job/agent_job.py | 42 ++++++++++------------ 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/press/hooks.py b/press/hooks.py index 10e6a550302..2faddea5ec2 100644 --- a/press/hooks.py +++ b/press/hooks.py @@ -309,7 +309,7 @@ "press.press.doctype.database_server.database_server.update_database_schema_sizes", ], "* * * * * 0/5": [ - "press.press.doctype.agent_job.agent_job.poll_pending_jobs", + # "press.press.doctype.agent_job.agent_job.poll_pending_jobs", "press.press.doctype.press_webhook_log.press_webhook_log.process", "press.press.doctype.telegram_message.telegram_message.send_telegram_message", "press.press.doctype.agent_update.agent_update.process_bulk_agent_update", diff --git a/press/press/doctype/agent_job/agent_job.py b/press/press/doctype/agent_job/agent_job.py index 4dac20b5634..630117b3479 100644 --- a/press/press/doctype/agent_job/agent_job.py +++ b/press/press/doctype/agent_job/agent_job.py @@ -423,23 +423,18 @@ def job_detail(job): def publish_update(job): message = job_detail(job) + + # Update the custom frontend listener frappe.publish_realtime(event="agent_job_update", doctype="Agent Job", docname=job, message=message) + # Force the Agent Job form and list to auto-update frappe.publish_realtime( - event="doc_update", - doctype="Agent Job", - docname=job, - message={"doctype": "Agent Job", "name": job}, - after_commit=True, + event="doc_update", doctype="Agent Job", docname=job, message={"doctype": "Agent Job", "name": job} ) - - # publish event for agent job list to update in dashboard - # we are doing this since process agent job doesn't emit list_update for job due to set_value frappe.publish_realtime(event="list_update", message={"doctype": "Agent Job", "name": job}) - # publish event for site to show job running on dashboard and update site - # we are doing this since process agent job doesn't emit doc_update for site due to set_value - if message["site"]: + # Force the Site form to auto-update + if message.get("site"): frappe.publish_realtime( event="doc_update", doctype="Site", @@ -453,6 +448,18 @@ def publish_update(job): }, ) + # Force all individual Agent Job Steps to auto-update + step_docnames = frappe.get_all("Agent Job Step", filters={"agent_job": job}, pluck="name") + + for step_name in step_docnames: + frappe.publish_realtime( + event="doc_update", + doctype="Agent Job Step", + docname=step_name, + message={"doctype": "Agent Job Step", "name": step_name}, + ) + frappe.publish_realtime(event="list_update", message={"doctype": "Agent Job Step", "name": step_name}) + @timer def poll_random_jobs(agent: Agent, pending_ids): @@ -783,19 +790,6 @@ def update_step(step_name, step): }, ) - frappe.publish_realtime( - event="doc_update", - doctype="Agent Job Step", - docname=step_name, - message={"doctype": "Agent Job Step", "name": step_name}, - after_commit=True, - ) - - # Force the Agent Job Step list view to refresh - frappe.publish_realtime( - event="list_update", message={"doctype": "Agent Job Step", "name": step_name}, after_commit=True - ) - def skip_pending_steps(job_name): frappe.db.sql( From ee77c1d107af64747f8cee4f850d4d74b349cfc5 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Fri, 15 May 2026 14:54:11 +0000 Subject: [PATCH 21/59] feat(agent-job): Retry schedule for undelivered jobs --- .cspell.json | 1 + press/hooks.py | 3 ++- press/press/doctype/agent_job/agent_job.py | 31 ++++++++++++++++++++-- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/.cspell.json b/.cspell.json index a7eb32d7238..ab1fc1507f0 100644 --- a/.cspell.json +++ b/.cspell.json @@ -594,6 +594,7 @@ "sprintf", "squashfs", "Srednekolymsk", + "srem", "Starke", "stdc", "stime", diff --git a/press/hooks.py b/press/hooks.py index 2faddea5ec2..96d8dcd6a53 100644 --- a/press/hooks.py +++ b/press/hooks.py @@ -309,7 +309,7 @@ "press.press.doctype.database_server.database_server.update_database_schema_sizes", ], "* * * * * 0/5": [ - # "press.press.doctype.agent_job.agent_job.poll_pending_jobs", + "press.press.doctype.agent_job.agent_job.poll_pending_jobs", "press.press.doctype.press_webhook_log.press_webhook_log.process", "press.press.doctype.telegram_message.telegram_message.send_telegram_message", "press.press.doctype.agent_update.agent_update.process_bulk_agent_update", @@ -355,6 +355,7 @@ "press.workflow_engine.doctype.press_workflow.press_workflow.retry_workflow_callbacks", ], "* * * * *": [ + "press.press.doctype.agent_job.agent_job.retry_poll", "press.press.doctype.virtual_disk_snapshot.virtual_disk_snapshot.sync_physical_backup_snapshots", "press.workflow_engine.doctype.press_workflow_task.press_workflow_task.retry_tasks", "press.press.doctype.deploy_candidate_build.deploy_candidate_build.run_scheduled_builds", diff --git a/press/press/doctype/agent_job/agent_job.py b/press/press/doctype/agent_job/agent_job.py index 630117b3479..5a128f289c8 100644 --- a/press/press/doctype/agent_job/agent_job.py +++ b/press/press/doctype/agent_job/agent_job.py @@ -194,6 +194,8 @@ def create_http_request(self): process_job_updates(self.name) else: + frappe.cache().sadd("undelivered_jobs", f"{self.server_type}:{self.server}") + self.set_status_and_next_retry_at() def log_creation(self): @@ -431,10 +433,14 @@ def publish_update(job): frappe.publish_realtime( event="doc_update", doctype="Agent Job", docname=job, message={"doctype": "Agent Job", "name": job} ) + + # publish event for agent job list to update in dashboard + # we are doing this since process agent job doesn't emit list_update for job due to set_value frappe.publish_realtime(event="list_update", message={"doctype": "Agent Job", "name": job}) - # Force the Site form to auto-update - if message.get("site"): + # publish event for site to show job running on dashboard and update site + # we are doing this since process agent job doesn't emit doc_update for site due to set_value + if message["site"]: frappe.publish_realtime( event="doc_update", doctype="Site", @@ -849,6 +855,27 @@ def retry_undelivered_jobs(server): process_job_updates(job_name) +def retry_poll(): + servers = frappe.cache().smembers("undelivered_jobs") + + for server_key in servers: + if isinstance(server_key, bytes): + server_key = server_key.decode() + + server_type, server = server_key.split(":", 1) + + frappe.cache().srem("undelivered_jobs", server_key) + + retry_undelivered_jobs( + frappe._dict( + { + "server": server, + "server_type": server_type, + } + ) + ) + + def queued_jobs(): from frappe.utils.background_jobs import get_jobs From 5019d3268ad88fd7c8eabb03bad743d0e97e651c Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sun, 17 May 2026 07:20:25 +0000 Subject: [PATCH 22/59] feat(press-settings): Add feature flag for agent job push --- press/api/callbacks.py | 4 ++++ press/press/doctype/agent_job/agent_job.py | 4 ++++ .../press/doctype/press_settings/press_settings.json | 11 +++++++++-- press/press/doctype/press_settings/press_settings.py | 1 + 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/press/api/callbacks.py b/press/api/callbacks.py index 90f0e24f7e5..d1696c83d19 100644 --- a/press/api/callbacks.py +++ b/press/api/callbacks.py @@ -127,6 +127,10 @@ def callback(job_id: str | None = None): @frappe.whitelist(allow_guest=True) @rate_limit(limit=10, seconds=60) def update_job(job, server): + flag = frappe.db.get_single_value("Press Settings", "push_feature") + if not flag: + return + if not job: return diff --git a/press/press/doctype/agent_job/agent_job.py b/press/press/doctype/agent_job/agent_job.py index 5a128f289c8..e30f13f5c75 100644 --- a/press/press/doctype/agent_job/agent_job.py +++ b/press/press/doctype/agent_job/agent_job.py @@ -856,6 +856,10 @@ def retry_undelivered_jobs(server): def retry_poll(): + flag = frappe.db.get_single_value("Press Settings", "push_feature") + if not flag: + return + servers = frappe.cache().smembers("undelivered_jobs") for server_key in servers: diff --git a/press/press/doctype/press_settings/press_settings.json b/press/press/doctype/press_settings/press_settings.json index 20df3a08acc..0fcaf963e1c 100644 --- a/press/press/doctype/press_settings/press_settings.json +++ b/press/press/doctype/press_settings/press_settings.json @@ -207,6 +207,7 @@ "column_break_105", "agent_github_access_token", "branch", + "push_feature", "lets_encrypt_section", "certbot_directory", "webroot_directory", @@ -1710,11 +1711,17 @@ "fieldname": "auto_upgrade_dependencies", "fieldtype": "Check", "label": "Auto Upgrade Dependencies" + }, + { + "default": "0", + "fieldname": "push_feature", + "fieldtype": "Check", + "label": "Push Feature" } ], "issingle": 1, "links": [], - "modified": "2026-04-30 18:44:43.851468", + "modified": "2026-05-17 11:40:39.696033", "modified_by": "Administrator", "module": "Press", "name": "Press Settings", @@ -1737,4 +1744,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/press/press/doctype/press_settings/press_settings.py b/press/press/doctype/press_settings/press_settings.py index ffdbff3c55f..153c680731c 100644 --- a/press/press/doctype/press_settings/press_settings.py +++ b/press/press/doctype/press_settings/press_settings.py @@ -196,6 +196,7 @@ class PressSettings(Document): print_format: DF.Data | None production_server_ip: DF.Data | None publish_docs: DF.Check + push_feature: DF.Check razorpay_key_id: DF.Data | None razorpay_key_secret: DF.Password | None razorpay_webhook_secret: DF.Data | None From 386b8b18f321fc0ce15d5c05c73760131ac00029 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sun, 17 May 2026 11:15:56 +0000 Subject: [PATCH 23/59] fix(agent-auth): Dual token window should match ansible timeout --- press/press/doctype/agent_auth/agent_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/press/press/doctype/agent_auth/agent_auth.py b/press/press/doctype/agent_auth/agent_auth.py index fa351ee0013..2a3a419b364 100644 --- a/press/press/doctype/agent_auth/agent_auth.py +++ b/press/press/doctype/agent_auth/agent_auth.py @@ -53,7 +53,7 @@ def _regenerate_token(self): frappe.cache().set_value( f"{self.server}_regenerate_public_key", self.regenerate_public_key, - expires_in_sec=60, # 1 min + expires_in_sec=600, # Ansible timeout ) server: BaseServer = frappe.get_doc( From 70897504a9d81fd48e36c57428d8907cb1d734cb Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sun, 17 May 2026 11:28:01 +0000 Subject: [PATCH 24/59] fix(agent-auth): Reload to avoid stale regenerate public key --- press/press/doctype/agent_auth/agent_auth.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/press/press/doctype/agent_auth/agent_auth.py b/press/press/doctype/agent_auth/agent_auth.py index 2a3a419b364..ade87ab7ef2 100644 --- a/press/press/doctype/agent_auth/agent_auth.py +++ b/press/press/doctype/agent_auth/agent_auth.py @@ -36,6 +36,8 @@ def _regenerate_token(self): lock_key = f"agent_auth_regeneration:{self.server}" with frappe.cache().lock(lock_key, timeout=600): + self.reload() + # already rotating if self.regenerate_public_key: return From 2ca82791173273993527bc181d3ea6f590d97934 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sun, 17 May 2026 11:31:04 +0000 Subject: [PATCH 25/59] fix(agent): Validate callback token format --- press/agent.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/press/agent.py b/press/agent.py index acb5035835c..4fb105e22cf 100644 --- a/press/agent.py +++ b/press/agent.py @@ -997,6 +997,10 @@ def get_regenerate_public_key(self): def _verify_request_token(self, token: str): from cryptography.exceptions import InvalidSignature + parts = token.split(".") + if len(parts) != 2: + raise ValueError("Malformed token") + payload_b64, signature_b64 = token.split(".") payload_bytes = base64.urlsafe_b64decode(payload_b64 + "==") From 0f1c0b9655ad9fffa37d4745545b9f76de6ab9b2 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sun, 17 May 2026 12:49:22 +0000 Subject: [PATCH 26/59] fix(server): Check ansible status before setup equals 1 --- press/api/callbacks.py | 2 ++ press/press/doctype/server/server.py | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/press/api/callbacks.py b/press/api/callbacks.py index d1696c83d19..9d2cddfd9c6 100644 --- a/press/api/callbacks.py +++ b/press/api/callbacks.py @@ -150,5 +150,7 @@ def update_job(job, server): filters={"job_id": job["id"]}, as_dict=True, ) + if not job_doc: + return handle_polled_job(polled_job=job, job=job_doc) diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index 17278238ab9..0e92a3573bc 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -1993,7 +1993,7 @@ def _generate_and_activate_key(self) -> str | None: public_key_b64 = base64.b64encode(public_key_bytes).decode() - frappe.cache.delete_key(f"{auth.server}_agent_public_key") + frappe.cache().delete_key(f"{auth.server}_agent_public_key") auth.public_key = public_key_b64 return private_key_str @@ -2012,7 +2012,11 @@ def _setup_agent_auth(self): port=self._ssh_port(), variables={"agent_token": agent_token}, ) - ansible.run() + result = ansible.run() + if result.status != "Success": + log_error("Agent auth setup playbook failed", server=self.as_dict()) + return + auth.is_agent_auth_setup = 1 auth.save(ignore_permissions=True) From db8cff193e2609f966e37a3b1704db2ab9765372 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 05:49:30 +0000 Subject: [PATCH 27/59] fix(server): Detach private key and attach agent token to playbook --- press/press/doctype/database_server/database_server.py | 7 +++++-- press/press/doctype/proxy_server/proxy_server.py | 3 ++- press/press/doctype/server/server.py | 3 ++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/press/press/doctype/database_server/database_server.py b/press/press/doctype/database_server/database_server.py index fc065a5f700..f92526eef95 100644 --- a/press/press/doctype/database_server/database_server.py +++ b/press/press/doctype/database_server/database_server.py @@ -823,7 +823,7 @@ def _setup_server(self): "monitoring_password": config.monitoring_password, "log_server": config.log_server, "kibana_password": config.kibana_password, - "private_key": config.private_key, + "agent_token": config.agent_token, "private_ip": self.private_ip, "server_id": self.server_id, "allocator": self.memory_allocator.lower(), @@ -867,6 +867,9 @@ def _get_config(self): else: kibana_password = None + private_key = self._generate_and_activate_key() + token = self.sign_agent_token(private_key) + return frappe._dict( dict( agent_password=self.get_password("agent_password"), @@ -879,7 +882,7 @@ def _get_config(self): ), log_server=log_server, kibana_password=kibana_password, - private_key=self._generate_and_activate_key(), + agent_token=token, ) ) diff --git a/press/press/doctype/proxy_server/proxy_server.py b/press/press/doctype/proxy_server/proxy_server.py index 63b840b43b8..4602bc98442 100644 --- a/press/press/doctype/proxy_server/proxy_server.py +++ b/press/press/doctype/proxy_server/proxy_server.py @@ -127,6 +127,7 @@ def _setup_server(self): kibana_password = None private_key = self._generate_and_activate_key() + agent_token = self.sign_agent_token(private_key) try: ansible = Ansible( @@ -147,7 +148,7 @@ def _setup_server(self): "certificate_full_chain": certificate.full_chain, "certificate_intermediate_chain": certificate.intermediate_chain, "press_url": frappe.utils.get_url(), - "private_key": private_key, + "agent_token": agent_token, }, ) play = ansible.run() diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index 0e92a3573bc..97f31f8f98e 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -3203,6 +3203,7 @@ def _setup_server(self): log_server, kibana_password = self.get_log_server() agent_sentry_dsn = frappe.db.get_single_value("Press Settings", "agent_sentry_dsn") private_key = self._generate_and_activate_key() + agent_token = self.sign_agent_token(private_key) # If database server is set, then define db port under configuration db_port = ( @@ -3226,7 +3227,6 @@ def _setup_server(self): "agent_repository_url": agent_repository_url, "agent_branch": agent_branch, "agent_sentry_dsn": agent_sentry_dsn, - "private_key": private_key, "monitoring_password": self.get_monitoring_password(), "log_server": log_server, "kibana_password": kibana_password, @@ -3237,6 +3237,7 @@ def _setup_server(self): "db_port": db_port, "agent_repository_branch_or_commit_ref": self.get_agent_repository_branch(), "agent_update_args": " --skip-repo-setup=true", + "agent_token": agent_token, "nat_gateway_ip": self.get_nat_gateway_ip(), **self.get_mount_variables(), }, From fcd7d5375cd4b2dd472625ee41c0f4da3d603e5e Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 06:15:18 +0000 Subject: [PATCH 28/59] fix(server): Save agent auth in setup server --- .../doctype/database_server/database_server.py | 13 +++++++------ press/press/doctype/proxy_server/proxy_server.py | 4 +++- press/press/doctype/server/server.py | 7 +++---- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/press/press/doctype/database_server/database_server.py b/press/press/doctype/database_server/database_server.py index f92526eef95..ff619e4439b 100644 --- a/press/press/doctype/database_server/database_server.py +++ b/press/press/doctype/database_server/database_server.py @@ -807,6 +807,10 @@ def validate_server_id(self): def _setup_server(self): config = self._get_config() + private_key = self._generate_and_activate_key() + agent_token = self.sign_agent_token(private_key) + auth = self.agent_auth + try: ansible = Ansible( playbook="self_hosted_db.yml" if getattr(self, "is_self_hosted", False) else "database.yml", @@ -823,7 +827,7 @@ def _setup_server(self): "monitoring_password": config.monitoring_password, "log_server": config.log_server, "kibana_password": config.kibana_password, - "agent_token": config.agent_token, + "agent_token": agent_token, "private_ip": self.private_ip, "server_id": self.server_id, "allocator": self.memory_allocator.lower(), @@ -843,7 +847,8 @@ def _setup_server(self): if play.status == "Success": self.status = "Active" self.is_server_setup = True - self.is_agent_auth_setup = 1 + auth.is_agent_auth_setup = 1 + auth.save(ignore_permissions=True) self.process_hybrid_server_setup() if self.provider == "DigitalOcean": # Adjusting docker permissions @@ -867,9 +872,6 @@ def _get_config(self): else: kibana_password = None - private_key = self._generate_and_activate_key() - token = self.sign_agent_token(private_key) - return frappe._dict( dict( agent_password=self.get_password("agent_password"), @@ -882,7 +884,6 @@ def _get_config(self): ), log_server=log_server, kibana_password=kibana_password, - agent_token=token, ) ) diff --git a/press/press/doctype/proxy_server/proxy_server.py b/press/press/doctype/proxy_server/proxy_server.py index 4602bc98442..a19142fd096 100644 --- a/press/press/doctype/proxy_server/proxy_server.py +++ b/press/press/doctype/proxy_server/proxy_server.py @@ -128,6 +128,7 @@ def _setup_server(self): private_key = self._generate_and_activate_key() agent_token = self.sign_agent_token(private_key) + auth = self.agent_auth try: ansible = Ansible( @@ -156,7 +157,8 @@ def _setup_server(self): if play.status == "Success": self.status = "Active" self.is_server_setup = True - self.is_agent_auth_setup = 1 + auth.is_agent_auth_setup = 1 + auth.save(ignore_permissions=True) else: self.status = "Broken" except Exception: diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index 97f31f8f98e..4ea6faff76f 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -95,9 +95,6 @@ class AutoScaleTriggerRow(TypedDict): class BaseServer(Document, TagHelpers): - public_key: str | None - is_agent_auth_setup: int | None - dashboard_fields = ( "title", "plan", @@ -3204,6 +3201,7 @@ def _setup_server(self): agent_sentry_dsn = frappe.db.get_single_value("Press Settings", "agent_sentry_dsn") private_key = self._generate_and_activate_key() agent_token = self.sign_agent_token(private_key) + auth = self.agent_auth # If database server is set, then define db port under configuration db_port = ( @@ -3248,7 +3246,8 @@ def _setup_server(self): if play.status == "Success": self.status = "Active" self.is_server_setup = True - self.is_agent_auth_setup = 1 + auth.is_agent_auth_setup = 1 + auth.save(ignore_permissions=True) if self.provider == "DigitalOcean": # To adjust docker permissions self.reboot() From 82c95c0a04c8fe8570fed49b53e31d8d2042614b Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 06:54:43 +0000 Subject: [PATCH 29/59] fix(agent): Add validation for agent public keys --- press/agent.py | 73 +++++++++++++--------- press/press/doctype/agent_job/agent_job.py | 19 +++--- 2 files changed, 52 insertions(+), 40 deletions(-) diff --git a/press/agent.py b/press/agent.py index 4fb105e22cf..cceb3fbb701 100644 --- a/press/agent.py +++ b/press/agent.py @@ -958,21 +958,26 @@ def get_agent_public_key(self): public_key = frappe.cache().get_value(key) - if not public_key: - agent_auth: AgentAuth = frappe.get_doc( - "Agent Auth", - self.server, - ) + if public_key: + return public_key - public_key = agent_auth.public_key + try: + agent_auth = frappe.get_doc("Agent Auth", self.server) + except frappe.DoesNotExistError: + return None - if not frappe.cache().get_value(f"{self.server}_regenerate_public_key"): - # Don't set cache while regenerating. Old public key may get cached again. - frappe.cache().set_value( - key, - public_key, - expires_in_sec=3600, - ) + if not agent_auth.public_key: + return None + + public_key = agent_auth.public_key + + if not frappe.cache().get_value(f"{self.server}_regenerate_public_key"): + # Don't set cache while regenerating. Old public key may get cached again. + frappe.cache().set_value( + key, + public_key, + expires_in_sec=3600, + ) return public_key @@ -994,40 +999,46 @@ def get_regenerate_public_key(self): return regenerate_key - def _verify_request_token(self, token: str): + def _is_token_verified(self, public_keys, signature, payload_bytes): from cryptography.exceptions import InvalidSignature + for key in public_keys: + try: + public_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(key)) + + public_key.verify(signature, payload_bytes) + + return True + + except (InvalidSignature, ValueError, TypeError): + pass + + return False + + def _verify_request_token(self, token: str): parts = token.split(".") if len(parts) != 2: raise ValueError("Malformed token") - payload_b64, signature_b64 = token.split(".") + payload_b64, signature_b64 = parts payload_bytes = base64.urlsafe_b64decode(payload_b64 + "==") signature = base64.urlsafe_b64decode(signature_b64 + "==") - public_keys = [self.get_agent_public_key()] + public_keys = [] - regenerate_key = self.get_regenerate_public_key() + primary_key = self.get_agent_public_key() + if primary_key: + public_keys.append(primary_key) + regenerate_key = self.get_regenerate_public_key() if regenerate_key: public_keys.append(regenerate_key) - verified = False - - for key in public_keys: - try: - public_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(key)) - - public_key.verify(signature, payload_bytes) - - verified = True - break - - except InvalidSignature: - pass + if not public_keys: + raise ValueError("No public keys available for verification") - if not verified: + if not self._is_token_verified(public_keys, signature, payload_bytes): raise ValueError("Invalid token signature") payload = json.loads(payload_bytes) diff --git a/press/press/doctype/agent_job/agent_job.py b/press/press/doctype/agent_job/agent_job.py index e30f13f5c75..906616faade 100644 --- a/press/press/doctype/agent_job/agent_job.py +++ b/press/press/doctype/agent_job/agent_job.py @@ -868,16 +868,17 @@ def retry_poll(): server_type, server = server_key.split(":", 1) - frappe.cache().srem("undelivered_jobs", server_key) - - retry_undelivered_jobs( - frappe._dict( - { - "server": server, - "server_type": server_type, - } + try: + retry_undelivered_jobs( + frappe._dict( + { + "server": server, + "server_type": server_type, + } + ) ) - ) + finally: + frappe.cache().srem("undelivered_jobs", server_key) def queued_jobs(): From 6e9f1d65c029859f21153e63d66e2e9d84e17e13 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 07:06:02 +0000 Subject: [PATCH 30/59] refactor(callbacks): Increase rate limit from 10 to 500 for update job --- press/api/callbacks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/press/api/callbacks.py b/press/api/callbacks.py index 9d2cddfd9c6..bfa32a767fb 100644 --- a/press/api/callbacks.py +++ b/press/api/callbacks.py @@ -125,7 +125,7 @@ def callback(job_id: str | None = None): @frappe.whitelist(allow_guest=True) -@rate_limit(limit=10, seconds=60) +@rate_limit(limit=500, seconds=60) def update_job(job, server): flag = frappe.db.get_single_value("Press Settings", "push_feature") if not flag: From 596671a6b74f663855bcec1d397e8d61f37c2111 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 07:25:51 +0000 Subject: [PATCH 31/59] fix(server): Idempotent agent auth setup --- press/press/doctype/server/server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index 4ea6faff76f..d8d79b2a8ff 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -1999,6 +1999,9 @@ def _setup_agent_auth(self): try: auth = self.agent_auth + if auth.public_key and auth.is_agent_auth_setup: + return + private_key = self._generate_and_activate_key() agent_token = self.sign_agent_token(private_key) @@ -2019,7 +2022,6 @@ def _setup_agent_auth(self): auth.save(ignore_permissions=True) except Exception: log_error("Agent Auth Setup Exception", server=self.as_dict()) - self.save() @frappe.whitelist() def collect_arm_images(self) -> str: From 0d40b337db7df8a5aa12799bf58f5d538aa045d8 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 08:04:42 +0000 Subject: [PATCH 32/59] fix(callbacks): Add server to filter --- press/api/callbacks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/press/api/callbacks.py b/press/api/callbacks.py index bfa32a767fb..5d309437667 100644 --- a/press/api/callbacks.py +++ b/press/api/callbacks.py @@ -147,7 +147,7 @@ def update_job(job, server): "callback_failure_count", "job_type", ], - filters={"job_id": job["id"]}, + filters={"job_id": job["id"], "server": server}, as_dict=True, ) if not job_doc: From f4bf67d52d0ca21466043b6c696eef7acd080970 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 08:09:42 +0000 Subject: [PATCH 33/59] fix(callbacks): Check the instance of job --- press/api/callbacks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/press/api/callbacks.py b/press/api/callbacks.py index 5d309437667..2922c2b3c7e 100644 --- a/press/api/callbacks.py +++ b/press/api/callbacks.py @@ -136,7 +136,8 @@ def update_job(job, server): verify_agent(server) - job = json.loads(job) + if isinstance(job, str): + job = json.loads(job) job_doc = frappe.get_value( "Agent Job", From 62c279279dd8f4a9625b390faf75613f611e5c2e Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 08:32:27 +0000 Subject: [PATCH 34/59] fix(agent-job): Remove undelivered jobs cache after its done --- press/press/doctype/agent_job/agent_job.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/press/press/doctype/agent_job/agent_job.py b/press/press/doctype/agent_job/agent_job.py index 906616faade..2f1323c0930 100644 --- a/press/press/doctype/agent_job/agent_job.py +++ b/press/press/doctype/agent_job/agent_job.py @@ -877,7 +877,13 @@ def retry_poll(): } ) ) - finally: + except Exception: + log_error( + "Retry undelivered jobs failed", + server=server, + server_type=server_type, + ) + else: frappe.cache().srem("undelivered_jobs", server_key) From c36446645d873c47a7e7caffbd9731f964b96c66 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 08:37:17 +0000 Subject: [PATCH 35/59] refactor(callbacks): Enqueue handle polled jobs --- press/api/callbacks.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/press/api/callbacks.py b/press/api/callbacks.py index 2922c2b3c7e..47de6bac36e 100644 --- a/press/api/callbacks.py +++ b/press/api/callbacks.py @@ -154,4 +154,9 @@ def update_job(job, server): if not job_doc: return - handle_polled_job(polled_job=job, job=job_doc) + frappe.enqueue( + handle_polled_job, + queue="short", + polled_job=job, + job=job_doc, + ) From acd656aca278b257508de34d829c90217ec6f194 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 08:46:41 +0000 Subject: [PATCH 36/59] fix(agent): Throw permission error if verification failed --- press/agent.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/press/agent.py b/press/agent.py index cceb3fbb701..16265338999 100644 --- a/press/agent.py +++ b/press/agent.py @@ -1053,11 +1053,12 @@ def _verify_request_token(self, token: str): def extract_and_verify_token(self, token): if not token: - raise ValueError("Unsigned request from agent") + frappe.throw("Unsigned request from agent", frappe.PermissionError) - self._verify_request_token( - token=token, - ) + try: + self._verify_request_token(token=token) + except ValueError: + frappe.throw_permission_error() def request(self, method, path, data=None, files=None, agent_job=None, raises=True): self.raise_if_past_requests_have_failed() From 2c18ae35a5fd89acc1e3b65ed8edd16f44c3279d Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 10:41:58 +0000 Subject: [PATCH 37/59] refactor(server): Reduce server on_update complexity --- press/press/doctype/server/server.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index ee0af2cf32c..2d83a8141f0 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -3004,6 +3004,24 @@ def validate_managed_database_service(self): else: self.managed_database_service = "" + def sync_database_server_public_status(self): + if not self.database_server: + return + + database_server_public = frappe.db.get_value( + "Database Server", + self.database_server, + "public", + ) + + if database_server_public != self.public: + frappe.db.set_value( + "Database Server", + self.database_server, + "public", + self.public, + ) + def on_update(self): # If Database Server is changed for the server then change it for all the benches if not self.is_new() and ( @@ -3016,10 +3034,7 @@ def on_update(self): bench.managed_database_service = self.managed_database_service bench.save() - if self.database_server: - database_server_public = frappe.db.get_value("Database Server", self.database_server, "public") - if database_server_public != self.public: - frappe.db.set_value("Database Server", self.database_server, "public", self.public) + self.sync_database_server_public_status() if not self.is_new() and self.has_value_changed("team"): self.update_subscription() From 7766468d92eb28f4e126dc340c1066880fa07ef6 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 14:14:14 +0000 Subject: [PATCH 38/59] feat(agent-auth): Add tests --- .cspell.json | 1580 ++++++++--------- press/api/tests/test_agent_auth.py | 33 + press/api/tests/test_callbacks.py | 50 + .../doctype/agent_auth/test_agent_auth.py | 30 +- .../press/doctype/agent_job/test_agent_job.py | 52 + .../database_server/database_server.py | 3 + .../database_server/test_database_server.py | 50 + .../doctype/proxy_server/test_proxy_server.py | 50 + press/press/doctype/server/test_server.py | 22 + press/tests/test_agent.py | 57 + 10 files changed, 1122 insertions(+), 805 deletions(-) create mode 100644 press/api/tests/test_agent_auth.py create mode 100644 press/api/tests/test_callbacks.py diff --git a/.cspell.json b/.cspell.json index 18ef6340bff..afe0e50a079 100644 --- a/.cspell.json +++ b/.cspell.json @@ -1,799 +1,787 @@ { - "version": "0.2", - "language": "en", - "allowCompoundWords": true, - "ignorePaths": [ - "dashboard/node_modules", - "**/assets", - "*.json", - "**.jinja2", - "**.j2", - "**.service", - "**.yml", - "test_**", - "**.conf", - "requirements.txt", - "dev-requirements.txt", - "press/utils/country_timezone.py", - ".secrets.baseline", - "**go.sum", - "libs/**" - ], - "words": [ - "2.4.6", - "Aaiun", - "Ababa", - "activites", - "Adak", - "adblockers", - "Addis", - "aditya", - "Adminstrator", - "aescts", - "afero", - "Agejt", - "aggs", - "Ajkr", - "Akbary", - "Akts", - "Åland", - "Anadyr", - "Andhra", - "ansari", - "Aqtau", - "Aqtobe", - "Araguaina", - "Arunachal", - "Asmera", - "asmfmt", - "asname", - "asrc", - "ATEXT", - "athul", - "Atikokan", - "Atka", - "atleast", - "atotto", - "Atyrau", - "auid", - "awalsh", - "awwzvf", - "aymanbagabas", - "backgound", - "Baja", - "Balamurali", - "Barthelemy", - "Barthélemy", - "Bator", - "behavior", - "behaviour", - "benbjohnson", - "BENTO", - "binlog", - "biosdevname", - "blkid", - "bofq", - "boto", - "Bouvet", - "bouy", - "buildx", - "Busingen", - "Billing", - "Cabo", - "CCONTENT", - "cellbuf", - "cellbug", - "CFWS", - "chdir", - "Chhattisgarh", - "Choibalsan", - "Chuuk", - "chsh", - "chzyer", - "cidata", - "cint", - "clamav", - "clas", - "cli", - "cloudimg", - "CMDLINE", - "CNAME", - "cnsistency", - "CODECOV", - "codespell", - "commitlint", - "Comod", - "COMPATBILITY", - "confs", - "Consolas", - "Containerised", - "coveragerc", - "cpath", - "cpcommerce", - "cpuid", - "cpus", - "creat", - "creds", - "Creston", - "Csvg", - "csvg", - "CTEXT", - "CTPBJ", - "Cuiaba", - "Cunha", - "cust", - "Dacca", - "Dadra", - "Danmarkshavn", - "Darkify", - "dateutil", - "davecgh", - "DAYOFMONTH", - "DAYOFWEEK", - "DAYOFYEAR", - "daum", - "dbgsym", - "dboptimize", - "dbserver", - "DBUS", - "dcbs", - "DCONTENT", - "ddeb", - "ddebs", - "ddl", - "dearmor", - "devscripts", - "devtmpfs", - "dffx", - "Dgzr", - "Dili", - "dmypy", - "DNOQOHHMYYI", - "dnsmasq", - "dnspython", - "dnsutils", - "doesnt", - "dont", - "DONTNEED", - "dpkg", - "dribbble", - "DSEes", - "DTEXT", - "duckdb", - "DUID", - "Dumont", - "EACCES", - "earlyoom", - "ecommerce", - "EDITMSG", - "Efate", - "efi", - "EHIKF", - "Eirunepe", - "elif", - "elts", - "emaill", - "emandate", - "Ensenada", - "EPERM", - "equivs", - "erikgeiser", - "erpdb", - "erpnext", - "erpnextcom", - "erpnextsmb", - "errgo", - "Eswatini", - "Eucla", - "euid", - "EVHT", - "execv", - "execve", - "exitst", - "Exlude", - "FADV", - "Fakaofo", - "faris", - "Faso", - "fchmod", - "fchmodat", - "fchown", - "fchownat", - "fcrestore", - "Fdvmq", - "FEFF", - "Fffphu", - "filippo", - "Fmbeo", - "Fpww", - "frappeclient", - "frappehr", - "Frappeio", - "frappeui", - "fremovexattr", - "fsetxattr", - "fstype", - "ftrace", - "ftruncate", - "Fung", - "FWUP", - "Fzqt", - "gcore", - "Gekx", - "gaierror", - "genproto", - "getdate", - "getitimer", - "gget", - "ghaction", - "ghead", - "githubusercontent", - "glfw", - "glog", - "gmxxxxcom", - "gnueabi", - "GOARCH", - "goasm", - "goccy", - "godebug", - "gofork", - "goidentity", - "gokrb", - "goleak", - "gonum", - "gopkg", - "gotool", - "Gozu", - "Gqttikk", - "grequests", - "gshadow", - "GSSAPI", - "gstin", - "gstinhide", - "gstinshow", - "gtid", - "gunicorn", - "gxzc", - "hakanensari", - "Haryana", - "hase", - "Haveli", - "hetzner", - "hdel", - "hdfs", - "hget", - "Himachal", - "honnef", - "hookpy", - "Hovd", - "hrms", - "hrtimers", - "hset", - "hsts", - "htpasswd", - "Hvyanc", - "ibdata", - "Ibhfb", - "ibtmp", - "iceber", - "ifaces", - "Ifalt", - "ifnames", - "ifnull", - "IGST", - "ikxn", - "ILIKE", - "imds", - "Incase", - "innodb", - "innoterra", - "inodes", - "inplace", - "interactjs", - "interner", - "Inuvik", - "invs", - "iour", - "iowait", - "ipaddress", - "ipdb", - "IPEYBICE", - "iputils", - "ipython", - "IRET", - "isatty", - "isin", - "isnotnull", - "istable", - "ITIMER", - "Jammu", - "jcmturner", - "jemalloc", - "Jharkhand", - "Jhuj", - "jmespath", - "JMWS", - "Jnsl", - "joomla", - "joxit", - "jscache", - "jsons", - "jstemmer", - "Jujuy", - "JZNG", - "Karnataka", - "kcontinue", - "kdhz", - "KGUJ", - "Khandyga", - "KHTML", - "Kiritimati", - "kisielk", - "Kitts", - "Kolkata", - "kontinue", - "Kralendijk", - "Kuala", - "Kvsc", - "kwarg", - "kwargs", - "Ladakh", - "Lakshadweep", - "Latrh", - "lchown", - "Leste", - "libc", - "libdevel", - "libharfbuzz", - "libpango", - "libpangocairo", - "libsm", - "libstdc", - "libx", - "libxcb", - "libxext", - "libxmuu", - "libxrender", - "Lindeman", - "llen", - "localds", - "logex", - "Longyearbyen", - "LOUAA", - "lpush", - "lqez", - "lrange", - "lremovexattr", - "lsetxattr", - "lucasb", - "Lumpur", - "luxon", - "Maarten", - "Madhya", - "MADKY", - "Mahe", - "makeprg", - "marcboeker", - "MARIADB", - "mariadbd", - "Marino", - "Marketpalce", - "mattn", - "Mayen", - "mbps", - "mccabe", - "Meghalaya", - "Menlo", - "mergify", - "Metlakatla", - "mhpd", - "Mhsc", - "Minh", - "missingok", - "Mizoram", - "mkdir", - "mkisofs", - "Mmckchk", - "mname", - "momentjs", - "Moresby", - "moto", - "Mpesa", - "msgprint", - "msisdn", - "Mtay", - "muieblackcat", - "Murdo", - "mxschmitt", - "myadmin", - "Mycp", - "myisam", - "mypma", - "mypy", - "mysqladmin", - "mysqld", - "mysqldb", - "Mywk", - "nach", - "Nadu", - "Nagar", - "ncdu", - "nedded", - "NEFT", - "Nera", - "netcfg", - "NFKH", - "NGROK", - "nineth", - "Nipigon", - "nistp", - "njsproj", - "nocompress", - "nofail", - "NOFORK", - "noozm", - "NOPASSWD", - "Noronha", - "Norte", - "notifempty", - "notin", - "nqhxc", - "ntfs", - "ntvs", - "Nuuk", - "nvme", - "Nxzjr", - "objx", - "Occurred", - "OCI", - "OCID", - "ocpu", - "ocpus", - "ocsp", - "Odisha", - "Ojinaga", - "Olgu", - "OLQY", - "ondismiss", - "onfail", - "oom", - "opasswd", - "OPENBLAS", - "opions", - "overriden", - "OWUVXXW", - "oxxk", - "Paasphrase", - "packagejsons", - "Pago", - "paise", - "Pangnirtung", - "paramiko", - "parentfield", - "parenttype", - "pborman", - "pckj", - "pckjs", - "Pedning", - "Pesa", - "pexpect", - "pfiles", - "pflag", - "Pfrw", - "pgrep", - "phpmyadmin", - "pids", - "Pjpw", - "pkcs", - "pkgs", - "pmadb", - "pmezard", - "Pmirojx", - "Pohnpei", - "popperjs", - "pppconfig", - "pppoeconf", - "pprof", - "Pradesh", - "primarys", - "prm", - "probability", - "proces", - "procs", - "proot", - "promql", - "protoc", - "psync", - "ptype", - "Puducherry", - "Punta", - "Pushkarev", - "pycache", - "pycups", - "pyngrok", - "pypika", - "pypr", - "pypr", - "pyproject", - "pypt", - "pyspy", - "PYTHONUNBUFFERED", - "pytz", - "pyunit", - "Pziu", - "QCONTENT", - "Qostanay", - "Qrcode", - "qrcode", - "QTEXT", - "Qyzylorda", - "rcfile", - "rdata", - "rdatatype", - "recognise", - "rediffmail", - "redisearch", - "referer", - "Regs", - "Releas", - "removexattr", - "reqd", - "Rerunnability", - "rerunnable", - "Réunion", - "Rhiv", - "Rhxk", - "Rica", - "RIOHXQEHM", - "Rioja", - "rivo", - "rname", - "rnyq", - "rogpeppe", - "rootfs", - "rpush", - "rrset", - "Rsya", - "rtype", - "rutwikhdev", - "ruzy", - "rzgre", - "saas", - "sadd", - "sahilm", - "Santo", - "saurabh", - "sbool", - "Scoresbysund", - "sda", - "sdext", - "sdf", - "sdg", - "sdist", - "sdomain", - "secho", - "Segoe", - "segs", - "seperate", - "serializability", - "setxattr", - "shadrak", - "shuralyov", - "signup", - "sina", - "SLXVDP", - "slugified", - "smembers", - "SNUBA", - "snuba", - "socketio", - "softirq", - "somes", - "sonner", - "spamd", - "splited", - "sprintf", - "squashfs", - "Srednekolymsk", + "version": "0.2", + "language": "en", + "allowCompoundWords": true, + "ignorePaths": [ + "dashboard/node_modules", + "**/assets", + "*.json", + "**.jinja2", + "**.j2", + "**.service", + "**.yml", + "test_**", + "**.conf", + "requirements.txt", + "dev-requirements.txt", + "press/utils/country_timezone.py", + ".secrets.baseline", + "**go.sum" + ], + "words": [ + "2.4.6", + "Aaiun", + "Ababa", + "activites", + "Adak", + "adblockers", + "Addis", + "aditya", + "Adminstrator", + "aescts", + "afero", + "Agejt", + "aggs", + "Ajkr", + "Akbary", + "Akts", + "Åland", + "Anadyr", + "Andhra", + "ansari", + "Aqtau", + "Aqtobe", + "Araguaina", + "Arunachal", + "Asmera", + "asmfmt", + "asname", + "asrc", + "ATEXT", + "athul", + "Atikokan", + "Atka", + "atleast", + "atotto", + "Atyrau", + "auid", + "awalsh", + "awwzvf", + "aymanbagabas", + "backgound", + "Baja", + "Balamurali", + "Barthelemy", + "Barthélemy", + "Bator", + "behavior", + "behaviour", + "benbjohnson", + "BENTO", + "binlog", + "biosdevname", + "blkid", + "bofq", + "boto", + "Bouvet", + "bouy", + "buildx", + "Busingen", + "Billing", + "Cabo", + "CCONTENT", + "cellbuf", + "cellbug", + "CFWS", + "chdir", + "Chhattisgarh", + "Choibalsan", + "Chuuk", + "chsh", + "chzyer", + "cidata", + "cint", + "clamav", + "clas", + "cli", + "cloudimg", + "CMDLINE", + "CNAME", + "cnsistency", + "CODECOV", + "codespell", + "commitlint", + "Comod", + "COMPATBILITY", + "confs", + "Consolas", + "Containerised", + "coveragerc", + "cpath", + "cpcommerce", + "cpuid", + "cpus", + "creat", + "creds", + "Creston", + "Csvg", + "csvg", + "CTEXT", + "CTPBJ", + "Cuiaba", + "Cunha", + "cust", + "Dacca", + "Dadra", + "Danmarkshavn", + "Darkify", + "dateutil", + "davecgh", + "DAYOFMONTH", + "DAYOFWEEK", + "DAYOFYEAR", + "daum", + "dbgsym", + "dboptimize", + "dbserver", + "DBUS", + "dcbs", + "DCONTENT", + "ddeb", + "ddebs", + "ddl", + "dearmor", + "devscripts", + "devtmpfs", + "dffx", + "Dgzr", + "Dili", + "dmypy", + "DNOQOHHMYYI", + "dnsmasq", + "dnspython", + "dnsutils", + "doesnt", + "dont", + "DONTNEED", + "dpkg", + "dribbble", + "DSEes", + "DTEXT", + "duckdb", + "DUID", + "Dumont", + "EACCES", + "earlyoom", + "ecommerce", + "EDITMSG", + "Efate", + "efi", + "EHIKF", + "Eirunepe", + "elif", + "elts", + "emaill", + "emandate", + "Ensenada", + "EPERM", + "equivs", + "erikgeiser", + "erpdb", + "erpnext", + "erpnextcom", + "erpnextsmb", + "errgo", + "Eswatini", + "Eucla", + "euid", + "EVHT", + "execv", + "execve", + "exitst", + "Exlude", + "FADV", + "Fakaofo", + "faris", + "Faso", + "fchmod", + "fchmodat", + "fchown", + "fchownat", + "fcrestore", + "Fdvmq", + "FEFF", + "Fffphu", + "filippo", + "Fmbeo", + "Fpww", + "frappeclient", + "frappehr", + "Frappeio", + "frappeui", + "fremovexattr", + "fsetxattr", + "fstype", + "ftrace", + "ftruncate", + "Fung", + "FWUP", + "Fzqt", + "gcore", + "Gekx", + "gaierror", + "genproto", + "getdate", + "getitimer", + "gget", + "ghaction", + "ghead", + "githubusercontent", + "glfw", + "glog", + "gmxxxxcom", + "gnueabi", + "GOARCH", + "goasm", + "goccy", + "godebug", + "gofork", + "goidentity", + "gokrb", + "goleak", + "gonum", + "gopkg", + "gotool", + "Gozu", + "Gqttikk", + "grequests", + "gshadow", + "GSSAPI", + "gstin", + "gstinhide", + "gstinshow", + "gtid", + "gunicorn", + "gxzc", + "hakanensari", + "Haryana", + "hase", + "Haveli", + "hetzner", + "hdel", + "hdfs", + "hget", + "Himachal", + "honnef", + "hookpy", + "Hovd", + "hrms", + "hrtimers", + "hset", + "hsts", + "htpasswd", + "Hvyanc", + "ibdata", + "Ibhfb", + "ibtmp", + "iceber", + "ifaces", + "Ifalt", + "ifnames", + "ifnull", + "IGST", + "ikxn", + "ILIKE", + "imds", + "Incase", + "innodb", + "innoterra", + "inodes", + "inplace", + "interactjs", + "interner", + "Inuvik", + "invs", + "iour", + "iowait", + "ipaddress", + "ipdb", + "IPEYBICE", + "iputils", + "ipython", + "IRET", + "isatty", + "isin", + "isnotnull", + "istable", + "ITIMER", + "Jammu", + "jcmturner", + "jemalloc", + "Jharkhand", + "Jhuj", + "jmespath", + "JMWS", + "Jnsl", + "joomla", + "joxit", + "jscache", + "jsons", + "jstemmer", + "Jujuy", + "JZNG", + "Karnataka", + "kcontinue", + "kdhz", + "KGUJ", + "Khandyga", + "KHTML", + "Kiritimati", + "kisielk", + "Kitts", + "Kolkata", + "kontinue", + "Kralendijk", + "Kuala", + "Kvsc", + "kwarg", + "kwargs", + "Ladakh", + "Lakshadweep", + "Latrh", + "lchown", + "Leste", + "libc", + "libdevel", + "libharfbuzz", + "libpango", + "libpangocairo", + "libsm", + "libstdc", + "libx", + "libxcb", + "libxext", + "libxmuu", + "libxrender", + "Lindeman", + "llen", + "localds", + "logex", + "Longyearbyen", + "LOUAA", + "lpush", + "lqez", + "lrange", + "lremovexattr", + "lsetxattr", + "lucasb", + "Lumpur", + "luxon", + "Maarten", + "Madhya", + "MADKY", + "Mahe", + "makeprg", + "marcboeker", + "MARIADB", + "mariadbd", + "Marino", + "Marketpalce", + "mattn", + "Mayen", + "mbps", + "mccabe", + "Meghalaya", + "Menlo", + "mergify", + "Metlakatla", + "mhpd", + "Mhsc", + "Minh", + "missingok", + "Mizoram", + "mkdir", + "mkisofs", + "Mmckchk", + "mname", + "momentjs", + "Moresby", + "moto", + "Mpesa", + "msgprint", + "msisdn", + "Mtay", + "muieblackcat", + "Murdo", + "mxschmitt", + "myadmin", + "Mycp", + "myisam", + "mypma", + "mypy", + "mysqladmin", + "mysqld", + "mysqldb", + "Mywk", + "nach", + "Nadu", + "Nagar", + "ncdu", + "nedded", + "NEFT", + "Nera", + "netcfg", + "NFKH", + "NGROK", + "nineth", + "Nipigon", + "nistp", + "njsproj", + "nocompress", + "nofail", + "NOFORK", + "noozm", + "NOPASSWD", + "Noronha", + "Norte", + "notifempty", + "notin", + "nqhxc", + "ntfs", + "ntvs", + "Nuuk", + "nvme", + "Nxzjr", + "objx", + "Occurred", + "OCI", + "OCID", + "ocpu", + "ocpus", + "ocsp", + "Odisha", + "Ojinaga", + "Olgu", + "OLQY", + "ondismiss", + "onfail", + "oom", + "opasswd", + "OPENBLAS", + "opions", + "overriden", + "OWUVXXW", + "oxxk", + "Paasphrase", + "packagejsons", + "Pago", + "paise", + "Pangnirtung", + "paramiko", + "parentfield", + "parenttype", + "pborman", + "pckj", + "pckjs", + "Pedning", + "Pesa", + "pexpect", + "pfiles", + "pflag", + "Pfrw", + "pgrep", + "phpmyadmin", + "pids", + "Pjpw", + "pkgs", + "pmadb", + "pmezard", + "Pmirojx", + "Pohnpei", + "popperjs", + "pppconfig", + "pppoeconf", + "pprof", + "Pradesh", + "primarys", + "prm", + "probability", + "proces", + "procs", + "proot", + "promql", + "protoc", + "psync", + "ptype", + "Puducherry", + "Punta", + "Pushkarev", + "pycache", + "pycups", + "pyngrok", + "pypika", + "pypr", + "pypr", + "pyproject", + "pypt", + "pyspy", + "PYTHONUNBUFFERED", + "pytz", + "pyunit", + "Pziu", + "QCONTENT", + "Qostanay", + "Qrcode", + "qrcode", + "QTEXT", + "Qyzylorda", + "rcfile", + "rdata", + "rdatatype", + "recognise", + "rediffmail", + "redisearch", + "referer", + "Regs", + "Releas", + "removexattr", + "reqd", + "Rerunnability", + "rerunnable", + "Réunion", + "Rhiv", + "Rhxk", + "Rica", + "RIOHXQEHM", + "Rioja", + "rivo", + "rname", + "rnyq", + "rogpeppe", + "rootfs", + "rpush", + "rrset", + "Rsya", + "rtype", + "rutwikhdev", + "ruzy", + "rzgre", + "saas", + "sadd", + "sahilm", + "Santo", + "saurabh", + "sbool", + "Scoresbysund", + "sda", + "sdext", + "sdf", + "sdg", + "sdist", + "sdomain", + "secho", + "Segoe", + "segs", + "seperate", + "serializability", + "setxattr", + "shadrak", + "shuralyov", + "signup", + "sina", + "SLXVDP", + "slugified", + "smembers", + "SNUBA", + "snuba", + "socketio", + "softirq", + "somes", + "sonner", + "spamd", + "splited", + "sprintf", + "squashfs", + "Srednekolymsk", "srem", - "Starke", - "stdc", - "stime", - "stkpush", - "stopasgroup", - "Storge", - "stretchr", - "stripnl", - "Strnm", - "supectl", - "supervisorctl", - "supervisord", - "swapuuid", - "SYMBOLICATOR", - "symbolicator", - "synchronise", - "Syowa", - "Syrus", - "sysrq", - "tanmoy", - "tanmoysrt", - "tanxxxxxxkar", - "tcmalloc", - "Telangana", - "termenv", - "Thgcy", - "tidb", - "Tiraspol", - "Tkndys", - "tldextract", - "Tmate", - "tmpfs", - "Tokelau", - "tomli", - "Tongatapu", - "TOOD", - "TOTP", - "totp", - "tqdm", - "Troso", - "TSZK", - "tupple", - "Tvyn", - "Twillio", - "udiff", - "Udxsrq", - "uefi", - "Uenf", - "Ujung", - "Ulaanbaatar", - "Ulan", - "unarchived", - "Unbilled", - "uncollectible", - "unfollow", - "unindex", - "unindexed", - "unindexing", - "uniseg", - "unlinkat", - "unparse", - "unpatch", - "unplugin", - "Unprovisioned", - "unscrub", - "unsuspended", - "Unsuspending", - "unsuspension", - "updadted", - "urandom", - "uring", - "Urville", - "USEDNS", - "Ushuaia", - "Uttar", - "Uttarakhand", - "Uzhgorod", - "vagrant", - "varkw", - "vasile", - "VBDHE", - "vcpu", - "vcpus", - "vda", - "Velho", - "venv", - "Vetur", - "vetur", - "Vevay", - "vfat", - "vimrc", - "virsh", - "virtualenv", - "Vite", - "vite", - "vitess", - "VMI", - "vmis", - "vnic", - "Vodacom", - "volid", - "vpus", - "vtprotobuf", - "vueuse", - "vxeg", - "Vzzq", - "Wazuh", - "wazuh", - "weasyprint", - "webp", - "Werkzeug", - "Winamac", - "witht", - "Wiue", - "wkhtmlto", - "WKHTMLTOPDF", - "wkhtmltox", - "Wpym", - "xampp", - "xauth", - "xcall", - "xerrors", - "xfonts", - "XHOMZ", - "xitongsys", - "xlink", - "Xpai", - "XPUT", - "Xrwmb", - "xvda", - "xvdf", - "xvdg", - "Xwgt", - "xyproto", - "Xyrw", - "Xzmq", - "Yakutat", - "Yancowinna", - "Yekq", - "Ynel", - "yxei", - "Yzuve", - "zeebo", - "zloirock", - "Zpyihv", - "ZSTD", - "Zvkq", - "botocore", - "reka", - "pydo", - "erpnextv", - "hrmsv", - "unyank", - "unyanking", - "unyanked", - "shirou", - "gopsutil", - "SSIZE", - "innobase", - "FSEG", - "XDES", - "FLST", - "pyotp", - "noopener", - "noreferrer", - "nosemgrep", - "centralise", - "PSIIO", - "journalctl", - "ptrs", - "serialised", - "setnx", - "ssiyad", - "Aradhya", - "Tripathi", - "siduck", - "prathameshkurunkar", - "Bowrna", - "vitepress", - "resolvconf", - "lsync", - "lsyncd", - "awk", - "gawk", - "picklable", - "ndarray", - "mult", - "ruleid", - "chatwoot", - "ssti", - "tcmalloc", - "libncurses", - "libncursesw", - "DWITHOUT", - "wextra", - "SONAME", - "decommitted", - "libgnutls", - "UDF" - ] + "Starke", + "stdc", + "stime", + "stkpush", + "stopasgroup", + "Storge", + "stretchr", + "stripnl", + "Strnm", + "supectl", + "supervisorctl", + "supervisord", + "swapuuid", + "SYMBOLICATOR", + "symbolicator", + "synchronise", + "Syowa", + "Syrus", + "sysrq", + "tanmoy", + "tanmoysrt", + "tanxxxxxxkar", + "Telangana", + "termenv", + "Thgcy", + "tidb", + "Tiraspol", + "Tkndys", + "tldextract", + "Tmate", + "tmpfs", + "Tokelau", + "tomli", + "Tongatapu", + "TOOD", + "TOTP", + "totp", + "tqdm", + "Troso", + "TSZK", + "tupple", + "Tvyn", + "Twillio", + "udiff", + "Udxsrq", + "uefi", + "Uenf", + "Ujung", + "Ulaanbaatar", + "Ulan", + "unarchived", + "Unbilled", + "uncollectible", + "unfollow", + "unindex", + "unindexed", + "unindexing", + "uniseg", + "unlinkat", + "unparse", + "unpatch", + "unplugin", + "Unprovisioned", + "unscrub", + "unsuspended", + "Unsuspending", + "unsuspension", + "updadted", + "urandom", + "uring", + "Urville", + "USEDNS", + "Ushuaia", + "Uttar", + "Uttarakhand", + "Uzhgorod", + "vagrant", + "varkw", + "vasile", + "VBDHE", + "vcpu", + "vcpus", + "vda", + "Velho", + "venv", + "Vetur", + "vetur", + "Vevay", + "vfat", + "vimrc", + "virsh", + "virtualenv", + "Vite", + "vite", + "vitess", + "VMI", + "vmis", + "vnic", + "Vodacom", + "volid", + "vpus", + "vtprotobuf", + "vueuse", + "vxeg", + "Vzzq", + "Wazuh", + "wazuh", + "weasyprint", + "webp", + "Werkzeug", + "Winamac", + "witht", + "Wiue", + "wkhtmlto", + "WKHTMLTOPDF", + "wkhtmltox", + "Wpym", + "xampp", + "xauth", + "xcall", + "xerrors", + "xfonts", + "XHOMZ", + "xitongsys", + "xlink", + "Xpai", + "XPUT", + "Xrwmb", + "xvda", + "xvdf", + "xvdg", + "Xwgt", + "xyproto", + "Xyrw", + "Xzmq", + "Yakutat", + "Yancowinna", + "Yekq", + "Ynel", + "yxei", + "Yzuve", + "zeebo", + "zloirock", + "Zpyihv", + "ZSTD", + "Zvkq", + "botocore", + "reka", + "pydo", + "erpnextv", + "hrmsv", + "unyank", + "unyanking", + "unyanked", + "shirou", + "gopsutil", + "SSIZE", + "innobase", + "FSEG", + "XDES", + "FLST", + "pyotp", + "noopener", + "noreferrer", + "nosemgrep", + "centralise", + "PSIIO", + "journalctl", + "ptrs", + "serialised", + "setnx", + "ssiyad", + "Aradhya", + "Tripathi", + "siduck", + "prathameshkurunkar", + "Bowrna", + "vitepress", + "resolvconf", + "lsync", + "lsyncd", + "awk", + "gawk", + "picklable", + "ndarray", + "mult", + "ruleid", + "chatwoot", + "ssti" + ] } diff --git a/press/api/tests/test_agent_auth.py b/press/api/tests/test_agent_auth.py new file mode 100644 index 00000000000..e57689a9f08 --- /dev/null +++ b/press/api/tests/test_agent_auth.py @@ -0,0 +1,33 @@ +from unittest import TestCase +from unittest.mock import Mock, patch + +import frappe + +from press.api.agent_auth import verify_agent + + +class TestAgentAuth(TestCase): + def tearDown(self): + frappe.db.rollback() + + def test_verify_agent_throws_without_token(self): + frappe.request.headers = {} + + self.assertRaises( + frappe.PermissionError, + verify_agent, + "test-server", + ) + + @patch("press.api.agent_auth.Agent") + def test_verify_agent_calls_extract_and_verify_token(self, mock_agent): + mock_instance = Mock() + mock_agent.return_value = mock_instance + + frappe.request.headers = {"X-Agent-Token": "test-token"} + + verify_agent("test-server") + + mock_agent.assert_called_once_with("test-server") + + mock_instance.extract_and_verify_token.assert_called_once_with("test-token") diff --git a/press/api/tests/test_callbacks.py b/press/api/tests/test_callbacks.py new file mode 100644 index 00000000000..62772b58dda --- /dev/null +++ b/press/api/tests/test_callbacks.py @@ -0,0 +1,50 @@ +from unittest import TestCase +from unittest.mock import patch + +import frappe + +from press.api.callbacks import update_job + + +class TestUpdateJob(TestCase): + def tearDown(self): + frappe.db.rollback() + + @patch("press.api.callbacks.verify_agent") + def test_update_job_returns_when_feature_flag_disabled(self, _mock_verify): + with patch("frappe.db.get_single_value", return_value=0): + result = update_job(job={"id": "123"}, server="test-server") + + self.assertIsNone(result) + + @patch("press.api.callbacks.verify_agent") + def test_update_job_returns_when_job_is_missing(self, _mock_verify): + with patch("frappe.db.get_single_value", return_value=1): + result = update_job(job=None, server="test-server") + + self.assertIsNone(result) + + @patch("press.api.callbacks.verify_agent") + @patch("frappe.enqueue") + @patch("frappe.get_value") + def test_update_job_enqueues_handle_polled_job( + self, + mock_get_value, + mock_enqueue, + _mock_verify, + ): + mock_get_value.return_value = { + "name": "job-1", + "job_id": "123", + "status": "Running", + "callback_failure_count": 0, + "job_type": "Deploy", + } + + with patch("frappe.db.get_single_value", return_value=1): + update_job( + job={"id": "123"}, + server="test-server", + ) + + mock_enqueue.assert_called_once() diff --git a/press/press/doctype/agent_auth/test_agent_auth.py b/press/press/doctype/agent_auth/test_agent_auth.py index b8d32e73122..b4d162a3750 100644 --- a/press/press/doctype/agent_auth/test_agent_auth.py +++ b/press/press/doctype/agent_auth/test_agent_auth.py @@ -1,23 +1,35 @@ # Copyright (c) 2026, Frappe and Contributors # See license.txt -# import frappe +from unittest.mock import patch + from frappe.tests import IntegrationTestCase, UnitTestCase +from press.press.doctype.agent_auth.agent_auth import regenerate_token + # On IntegrationTestCase, the doctype test records and all # link-field test record dependencies are recursively loaded # Use these module variables to add/remove to/from that list -EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] -IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +EXTRA_TEST_RECORD_DEPENDENCIES: list[str] = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES: list[str] = [] # eg. ["User"] class UnitTestAgentAuth(UnitTestCase): - """ - Unit tests for AgentAuth. - Use this class for testing individual functions and methods. - """ - - pass + @patch("frappe.enqueue_doc") + @patch("frappe.get_all") + def test_regenerate_token_enqueues_jobs( + self, + mock_get_all, + mock_enqueue_doc, + ): + mock_get_all.return_value = [ + {"name": "auth-1"}, + {"name": "auth-2"}, + ] + + regenerate_token() + + self.assertEqual(mock_enqueue_doc.call_count, 2) class IntegrationTestAgentAuth(IntegrationTestCase): diff --git a/press/press/doctype/agent_job/test_agent_job.py b/press/press/doctype/agent_job/test_agent_job.py index fdadcb8ed32..dced51006b1 100644 --- a/press/press/doctype/agent_job/test_agent_job.py +++ b/press/press/doctype/agent_job/test_agent_job.py @@ -295,3 +295,55 @@ def test_get_similar_in_execution_job(self): self.assertEqual(in_execution_job.name, job.name) frappe.db.set_single_value("Press Settings", "disable_agent_job_deduplication", True) + + @patch("press.press.doctype.agent_job.agent_job.retry_undelivered_jobs") + def test_retry_poll_removes_server_on_success( + self, + mock_retry, + ): + frappe.db.set_single_value("Press Settings", "push_feature", 1) + + frappe.cache().sadd( + "undelivered_jobs", + "Server:test-server", + ) + + from press.press.doctype.agent_job.agent_job import retry_poll + + retry_poll() + + servers = frappe.cache().smembers("undelivered_jobs") + + self.assertNotIn( + b"Server:test-server", + servers, + ) + + @patch("press.press.doctype.agent_job.agent_job.log_error") + @patch("press.press.doctype.agent_job.agent_job.retry_undelivered_jobs") + def test_retry_poll_keeps_server_on_failure( + self, + mock_retry, + mock_log_error, + ): + mock_retry.side_effect = Exception("failure") + + frappe.db.set_single_value("Press Settings", "push_feature", 1) + + frappe.cache().sadd( + "undelivered_jobs", + "Server:test-server", + ) + + from press.press.doctype.agent_job.agent_job import retry_poll + + retry_poll() + + servers = frappe.cache().smembers("undelivered_jobs") + + self.assertIn( + b"Server:test-server", + servers, + ) + + mock_log_error.assert_called_once() diff --git a/press/press/doctype/database_server/database_server.py b/press/press/doctype/database_server/database_server.py index 6dc9c424b76..045b76f767f 100644 --- a/press/press/doctype/database_server/database_server.py +++ b/press/press/doctype/database_server/database_server.py @@ -2696,6 +2696,9 @@ def process_add_binlogs_to_indexer_agent_job_update(job: AgentJob): if job.status != "Success": return + if not job.data: + return + json_data = json.loads(job.data) indexed_binlogs = json_data.get("indexed_binlogs", []) frappe.db.set_value( diff --git a/press/press/doctype/database_server/test_database_server.py b/press/press/doctype/database_server/test_database_server.py index 08c48ff68b2..18cc795200b 100644 --- a/press/press/doctype/database_server/test_database_server.py +++ b/press/press/doctype/database_server/test_database_server.py @@ -150,3 +150,53 @@ def test_adjust_memory_config_sets_memory_limits_with_some_buffer(self): ).value_int, int(15007.248 * 0.65), ) + + @patch("press.press.doctype.database_server.database_server.Ansible") + def test_setup_server_marks_agent_auth_as_setup(self, Mock_Ansible): + server = create_test_database_server() + + server._get_config = Mock( + return_value=frappe._dict( + { + "agent_password": "test", # pragma: allowlist secret + "agent_repository_url": "test", + "agent_branch": "main", + "monitoring_password": "test", # pragma: allowlist secret + "log_server": None, + "kibana_password": None, + "mariadb_root_password": "test", # pragma: allowlist secret + "certificate": frappe._dict( + { + "private_key": "key", # pragma: allowlist secret + "full_chain": "chain", + "intermediate_chain": "intermediate", + } + ), + } + ) + ) + + server._generate_and_activate_key = Mock(return_value="private-key") + server.sign_agent_token = Mock(return_value="agent-token") + server.process_hybrid_server_setup = Mock() + + auth = frappe._dict( + { + "is_agent_auth_setup": 0, + "save": Mock(), + } + ) + + server.agent_auth = auth + + play = frappe._dict({"status": "Success"}) + Mock_Ansible.return_value.run.return_value = play + + server._setup_server() + + server._generate_and_activate_key.assert_called_once() + server.sign_agent_token.assert_called_once_with("private-key") + + self.assertEqual(auth.is_agent_auth_setup, 1) + + auth.save.assert_called_once_with(ignore_permissions=True) diff --git a/press/press/doctype/proxy_server/test_proxy_server.py b/press/press/doctype/proxy_server/test_proxy_server.py index 1dfa545c9d5..3cdd18f4fbc 100644 --- a/press/press/doctype/proxy_server/test_proxy_server.py +++ b/press/press/doctype/proxy_server/test_proxy_server.py @@ -64,3 +64,53 @@ def test_failover_document_creation(self): self.assertTrue( frappe.db.exists("Proxy Failover", {"primary": proxy1.name, "secondary": proxy2.name}) ) + + @patch("press.press.doctype.proxy_server.proxy_server.Ansible") + def test_setup_server_marks_agent_auth_as_setup( + self, + Mock_Ansible, + ): + server = create_test_proxy_server() + + server.get_password = Mock(return_value="password") + server.get_agent_repository_url = Mock(return_value="repo-url") + + server._generate_and_activate_key = Mock(return_value="private-key") + server.sign_agent_token = Mock(return_value="agent-token") + + auth = frappe._dict( + { + "is_agent_auth_setup": 0, + "save": Mock(), + } + ) + + server.agent_auth = auth + + play = frappe._dict({"status": "Success"}) + Mock_Ansible.return_value.run.return_value = play + + with patch("frappe.get_doc") as mock_get_doc: + mock_get_doc.return_value = frappe._dict( + { + "private_key": "key", # pragma: allowlist secret + "full_chain": "chain", + "intermediate_chain": "intermediate", + "get_password": Mock(return_value="monitoring-password"), + } + ) + + server._setup_server() + + server._generate_and_activate_key.assert_called_once() + + server.sign_agent_token.assert_called_once_with("private-key") + + self.assertEqual( + auth.is_agent_auth_setup, + 1, + ) + + auth.save.assert_called_once_with( + ignore_permissions=True, + ) diff --git a/press/press/doctype/server/test_server.py b/press/press/doctype/server/test_server.py index 8fc7f0a5e78..1540b7a06b6 100644 --- a/press/press/doctype/server/test_server.py +++ b/press/press/doctype/server/test_server.py @@ -383,3 +383,25 @@ def test_server_with_more_memory_is_shortlisted_for_new_benches_and_incident_cre ) self.assertEqual(len(incidents), 1) self.assertEqual(incidents[0].server, self.high_mem_server.name) + + @patch("press.runner.Ansible.run") + @patch.object(BaseServer, "sign_agent_token") + @patch.object(BaseServer, "_generate_and_activate_key") + def test_setup_agent_auth_returns_when_already_setup( + self, + mock_generate_key, + mock_sign_token, + mock_ansible_run, + ): + server = create_test_server() + + auth = server.agent_auth + auth.public_key = "public-key" + auth.is_agent_auth_setup = 1 + auth.save(ignore_permissions=True) + + server._setup_agent_auth() + + mock_generate_key.assert_not_called() + mock_sign_token.assert_not_called() + mock_ansible_run.assert_not_called() diff --git a/press/tests/test_agent.py b/press/tests/test_agent.py index 0887f5c45e5..e9eaf781a16 100644 --- a/press/tests/test_agent.py +++ b/press/tests/test_agent.py @@ -1,6 +1,8 @@ # Copyright (c) 2024, Frappe and contributors # For license information, please see license.txt +from unittest.mock import patch + import frappe import requests import responses @@ -122,3 +124,58 @@ def test_remove_function_removes_failure_if_ping_succeeds(self): responses.assert_call_count(f"https://{server.name}:443/agent/ping", 1) self.assertEqual(frappe.db.count("Agent Request Failure", {"server": server.name}), 0) + + @patch.object(Agent, "get_agent_public_key") + @patch.object(Agent, "get_regenerate_public_key") + def test_verify_request_token_fails_without_public_keys( + self, + mock_regenerate_key, + mock_public_key, + ): + mock_public_key.return_value = None + mock_regenerate_key.return_value = None + + agent = Agent("test-server") + + self.assertRaises( + ValueError, + agent._verify_request_token, + "abc.def", + ) + + def test_verify_request_token_fails_for_malformed_token(self): + agent = Agent("test-server") + + self.assertRaises( + ValueError, + agent._verify_request_token, + "invalid-token", + ) + + @patch.object(Agent, "_verify_request_token") + def test_extract_and_verify_token_raises_permission_error( + self, + mock_verify, + ): + mock_verify.side_effect = ValueError() + + agent = Agent("test-server") + + self.assertRaises( + frappe.PermissionError, + agent.extract_and_verify_token, + "token", + ) + + def test_get_agent_public_key_returns_cached_key(self): + agent = Agent("test-server") + + frappe.cache().set_value( + "test-server_agent_public_key", + "cached-key", + ) + + self.assertEqual( + agent.get_agent_public_key(), + "cached-key", + ) From 9cd1279914dd7fd17cc12761319f64f4588f491b Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 15:34:14 +0000 Subject: [PATCH 39/59] fix(test): Fix mock tests --- press/api/tests/test_agent_auth.py | 4 ++-- press/press/doctype/agent_auth/test_agent_auth.py | 5 +++-- press/press/doctype/database_server/test_database_server.py | 2 ++ press/press/doctype/proxy_server/test_proxy_server.py | 2 ++ 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/press/api/tests/test_agent_auth.py b/press/api/tests/test_agent_auth.py index e57689a9f08..2df857e06ea 100644 --- a/press/api/tests/test_agent_auth.py +++ b/press/api/tests/test_agent_auth.py @@ -11,7 +11,7 @@ def tearDown(self): frappe.db.rollback() def test_verify_agent_throws_without_token(self): - frappe.request.headers = {} + frappe.local.request = frappe._dict({"headers": {}}) self.assertRaises( frappe.PermissionError, @@ -24,7 +24,7 @@ def test_verify_agent_calls_extract_and_verify_token(self, mock_agent): mock_instance = Mock() mock_agent.return_value = mock_instance - frappe.request.headers = {"X-Agent-Token": "test-token"} + frappe.local.request = frappe._dict({"headers": {"X-Agent-Token": "test-token"}}) verify_agent("test-server") diff --git a/press/press/doctype/agent_auth/test_agent_auth.py b/press/press/doctype/agent_auth/test_agent_auth.py index b4d162a3750..746522e21cb 100644 --- a/press/press/doctype/agent_auth/test_agent_auth.py +++ b/press/press/doctype/agent_auth/test_agent_auth.py @@ -3,6 +3,7 @@ from unittest.mock import patch +import frappe from frappe.tests import IntegrationTestCase, UnitTestCase from press.press.doctype.agent_auth.agent_auth import regenerate_token @@ -23,8 +24,8 @@ def test_regenerate_token_enqueues_jobs( mock_enqueue_doc, ): mock_get_all.return_value = [ - {"name": "auth-1"}, - {"name": "auth-2"}, + frappe._dict({"name": "auth-1"}), + frappe._dict({"name": "auth-2"}), ] regenerate_token() diff --git a/press/press/doctype/database_server/test_database_server.py b/press/press/doctype/database_server/test_database_server.py index 18cc795200b..7850e6321ee 100644 --- a/press/press/doctype/database_server/test_database_server.py +++ b/press/press/doctype/database_server/test_database_server.py @@ -192,6 +192,8 @@ def test_setup_server_marks_agent_auth_as_setup(self, Mock_Ansible): play = frappe._dict({"status": "Success"}) Mock_Ansible.return_value.run.return_value = play + server.save = Mock() + server._setup_server() server._generate_and_activate_key.assert_called_once() diff --git a/press/press/doctype/proxy_server/test_proxy_server.py b/press/press/doctype/proxy_server/test_proxy_server.py index 3cdd18f4fbc..9d89c3e7484 100644 --- a/press/press/doctype/proxy_server/test_proxy_server.py +++ b/press/press/doctype/proxy_server/test_proxy_server.py @@ -100,6 +100,8 @@ def test_setup_server_marks_agent_auth_as_setup( } ) + server.save = Mock() + server._setup_server() server._generate_and_activate_key.assert_called_once() From f9fe94a5c9d7e78d0f7fd7f5d01f1dd35cd038b7 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 15:59:46 +0000 Subject: [PATCH 40/59] feat(test): Add more agent auth tests --- press/press/doctype/server/test_server.py | 133 ++++++++++++++++++++++ press/tests/test_agent.py | 126 +++++++++++++++++++- 2 files changed, 258 insertions(+), 1 deletion(-) diff --git a/press/press/doctype/server/test_server.py b/press/press/doctype/server/test_server.py index 1540b7a06b6..468ac2acb7d 100644 --- a/press/press/doctype/server/test_server.py +++ b/press/press/doctype/server/test_server.py @@ -405,3 +405,136 @@ def test_setup_agent_auth_returns_when_already_setup( mock_generate_key.assert_not_called() mock_sign_token.assert_not_called() mock_ansible_run.assert_not_called() + + @patch("frappe.cache") + def test_generate_and_activate_key_sets_public_key_and_returns_private_key( + self, + mock_cache, + ): + server = create_test_server() + + auth = server.agent_auth + auth.public_key = None + auth.is_agent_auth_setup = 0 + + private_key = server._generate_and_activate_key() + + self.assertIsNotNone(private_key) + + self.assertIsNotNone(auth.public_key) + + mock_cache.return_value.delete_key.assert_called_once_with(f"{auth.server}_agent_public_key") + + def test_generate_and_activate_key_returns_none_when_already_setup(self): + server = create_test_server() + + auth = server.agent_auth + auth.public_key = "public-key" + auth.is_agent_auth_setup = 1 + + result = server._generate_and_activate_key() + + self.assertIsNone(result) + + def test_sign_agent_token_returns_none_without_private_key(self): + server = create_test_server() + + token = server.sign_agent_token(None) + + self.assertIsNone(token) + + def test_sign_agent_token_sets_expiry_and_returns_token(self): + server = create_test_server() + + private_key = server._generate_and_activate_key() + + token = server.sign_agent_token(private_key) + + self.assertIsNotNone(token) + + self.assertIn(".", token) + + self.assertIsNotNone(server.agent_auth.expires_in) + + @patch("press.runner.Ansible.run") + @patch.object(BaseServer, "sign_agent_token") + @patch.object(BaseServer, "_generate_and_activate_key") + def test_setup_agent_auth_marks_auth_as_setup_on_success( + self, + mock_generate_key, + mock_sign_token, + mock_ansible_run, + ): + server = create_test_server() + + mock_generate_key.return_value = "private-key" + mock_sign_token.return_value = "token" + + play = frappe._dict({"status": "Success"}) + mock_ansible_run.return_value = play + + auth = server.agent_auth + auth.save = Mock() + + server._setup_agent_auth() + + mock_generate_key.assert_called_once() + + mock_sign_token.assert_called_once_with("private-key") + + self.assertEqual( + auth.is_agent_auth_setup, + 1, + ) + + auth.save.assert_called_once_with( + ignore_permissions=True, + ) + + @patch("press.press.doctype.server.server.log_error") + @patch("press.runner.Ansible.run") + @patch.object(BaseServer, "sign_agent_token") + @patch.object(BaseServer, "_generate_and_activate_key") + def test_setup_agent_auth_logs_error_on_failed_playbook( + self, + mock_generate_key, + mock_sign_token, + mock_ansible_run, + mock_log_error, + ): + server = create_test_server() + + mock_generate_key.return_value = "private-key" + mock_sign_token.return_value = "token" + + play = frappe._dict({"status": "Failure"}) + mock_ansible_run.return_value = play + + auth = server.agent_auth + auth.save = Mock() + + server._setup_agent_auth() + + self.assertEqual( + auth.is_agent_auth_setup, + 0, + ) + + auth.save.assert_not_called() + + mock_log_error.assert_called_once() + + @patch("press.press.doctype.server.server.log_error") + @patch.object(BaseServer, "_generate_and_activate_key") + def test_setup_agent_auth_logs_error_on_exception( + self, + mock_generate_key, + mock_log_error, + ): + server = create_test_server() + + mock_generate_key.side_effect = Exception() + + server._setup_agent_auth() + + mock_log_error.assert_called_once() diff --git a/press/tests/test_agent.py b/press/tests/test_agent.py index e9eaf781a16..224b3ee1a3c 100644 --- a/press/tests/test_agent.py +++ b/press/tests/test_agent.py @@ -1,7 +1,10 @@ # Copyright (c) 2024, Frappe and contributors # For license information, please see license.txt -from unittest.mock import patch +import base64 +import json +import time +from unittest.mock import Mock, patch import frappe import requests @@ -179,3 +182,124 @@ def test_get_agent_public_key_returns_cached_key(self): agent.get_agent_public_key(), "cached-key", ) + + def test_get_agent_public_key_returns_none_when_agent_auth_missing(self): + agent = Agent("test-server") + + with patch( + "frappe.get_doc", + side_effect=frappe.DoesNotExistError, + ): + self.assertIsNone(agent.get_agent_public_key()) + + def test_get_regenerate_public_key_clears_db_flag(self): + agent = Agent("test-server") + + agent_auth = frappe._dict( + { + "regenerate_public_key": "old-key", + "save": Mock(), + } + ) + + with patch( + "frappe.get_doc", + return_value=agent_auth, + ): + result = agent.get_regenerate_public_key() + + self.assertIsNone(result) + + self.assertIsNone( + agent_auth.regenerate_public_key, + ) + + agent_auth.save.assert_called_once_with( + ignore_permissions=True, + ) + + @patch.object(Agent, "_is_token_verified") + @patch.object(Agent, "get_regenerate_public_key") + @patch.object(Agent, "get_agent_public_key") + def test_verify_request_token_fails_for_invalid_server( + self, + mock_public_key, + mock_regenerate_key, + mock_verify, + ): + mock_public_key.return_value = "public-key" + mock_regenerate_key.return_value = None + mock_verify.return_value = True + + payload = ( + base64.urlsafe_b64encode( + json.dumps( + { + "server": "wrong-server", + "exp": int(time.time()) + 1000, + } + ).encode() + ) + .decode() + .rstrip("=") + ) + + signature = ( + base64.urlsafe_b64encode( + b"signature", + ) + .decode() + .rstrip("=") + ) + + token = f"{payload}.{signature}" + + agent = Agent("test-server") + + self.assertRaises( + ValueError, + agent._verify_request_token, + token, + ) + + @patch.object(Agent, "_is_token_verified") + @patch.object(Agent, "get_regenerate_public_key") + @patch.object(Agent, "get_agent_public_key") + def test_verify_request_token_succeeds( + self, + mock_public_key, + mock_regenerate_key, + mock_verify, + ): + mock_public_key.return_value = "public-key" + mock_regenerate_key.return_value = None + mock_verify.return_value = True + + payload = ( + base64.urlsafe_b64encode( + json.dumps( + { + "server": "test-server", + "exp": int(time.time()) + 1000, + } + ).encode() + ) + .decode() + .rstrip("=") + ) + + signature = ( + base64.urlsafe_b64encode( + b"signature", + ) + .decode() + .rstrip("=") + ) + + token = f"{payload}.{signature}" + + agent = Agent("test-server") + + self.assertTrue( + agent._verify_request_token(token), + ) From afa304eda21c1b31e3aad16d41deb72c56531b75 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 16:16:34 +0000 Subject: [PATCH 41/59] fix(test-server): Only mock cache.delete_key --- press/press/doctype/server/test_server.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/press/press/doctype/server/test_server.py b/press/press/doctype/server/test_server.py index 468ac2acb7d..b8280daa90c 100644 --- a/press/press/doctype/server/test_server.py +++ b/press/press/doctype/server/test_server.py @@ -406,10 +406,10 @@ def test_setup_agent_auth_returns_when_already_setup( mock_sign_token.assert_not_called() mock_ansible_run.assert_not_called() - @patch("frappe.cache") + @patch("frappe.cache.delete_key") def test_generate_and_activate_key_sets_public_key_and_returns_private_key( self, - mock_cache, + mock_delete_key, ): server = create_test_server() @@ -423,7 +423,7 @@ def test_generate_and_activate_key_sets_public_key_and_returns_private_key( self.assertIsNotNone(auth.public_key) - mock_cache.return_value.delete_key.assert_called_once_with(f"{auth.server}_agent_public_key") + mock_delete_key.assert_called_once_with(f"{auth.server}_agent_public_key") def test_generate_and_activate_key_returns_none_when_already_setup(self): server = create_test_server() From dfdf8dcfa04633d9f6ef84fabeb6146b6201e28e Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 16:44:14 +0000 Subject: [PATCH 42/59] fix(test): Test fixes --- press/api/site.py | 18 +++++++++--------- press/press/doctype/server/test_server.py | 19 ------------------- press/tests/test_audit.py | 3 +++ 3 files changed, 12 insertions(+), 28 deletions(-) diff --git a/press/api/site.py b/press/api/site.py index d85eeb327f8..3c1019dfa14 100644 --- a/press/api/site.py +++ b/press/api/site.py @@ -197,7 +197,7 @@ def _new(site, server: str | None = None, ignore_plan_validation: bool = False): plan = site["plan"] app_plans = site.get("selected_app_plans") if not ignore_plan_validation: - validate_plan(bench.server, plan, is_new=True) + validate_plan(bench.server, "", plan, is_new=True) site = frappe.get_doc( { @@ -844,11 +844,11 @@ def _get_dedicated_server_info_for_release_group(release_group_name: str) -> dic Returns dict with: - case: str - one of: - - "dedicated_only_single" - exactly one dedicated server - - "dedicated_only_multiple" - multiple dedicated servers - - "user_choice_single" - one dedicated server and other public server(s) - "user_choice_multiple" - multiple dedicated servers and public server(s) - - "no_dedicated_server" + - "dedicated_only_single" - exactly one dedicated server + - "dedicated_only_multiple" - multiple dedicated servers + - "user_choice_single" - one dedicated server and other public server(s) + "user_choice_multiple" - multiple dedicated servers and public server(s) + - "no_dedicated_server" - dedicated_servers: list - Available dedicated servers for user selection """ current_team = get_current_team() @@ -2681,9 +2681,9 @@ def check_existing_upgrade_bench(name, version): which includes all the apps installed on the site. Returns: { - "exists": bool, - "bench_name": str or None, - "release_group": str or None, + "exists": bool, + "bench_name": str or None, + "release_group": str or None, } """ site_server = frappe.db.get_value("Site", name, "server") diff --git a/press/press/doctype/server/test_server.py b/press/press/doctype/server/test_server.py index b8280daa90c..67276d406c7 100644 --- a/press/press/doctype/server/test_server.py +++ b/press/press/doctype/server/test_server.py @@ -406,25 +406,6 @@ def test_setup_agent_auth_returns_when_already_setup( mock_sign_token.assert_not_called() mock_ansible_run.assert_not_called() - @patch("frappe.cache.delete_key") - def test_generate_and_activate_key_sets_public_key_and_returns_private_key( - self, - mock_delete_key, - ): - server = create_test_server() - - auth = server.agent_auth - auth.public_key = None - auth.is_agent_auth_setup = 0 - - private_key = server._generate_and_activate_key() - - self.assertIsNotNone(private_key) - - self.assertIsNotNone(auth.public_key) - - mock_delete_key.assert_called_once_with(f"{auth.server}_agent_public_key") - def test_generate_and_activate_key_returns_none_when_already_setup(self): server = create_test_server() diff --git a/press/tests/test_audit.py b/press/tests/test_audit.py index 3c1e9dde583..f680aa618a1 100644 --- a/press/tests/test_audit.py +++ b/press/tests/test_audit.py @@ -9,12 +9,14 @@ from press.press.doctype.press_settings.test_press_settings import ( create_test_press_settings, ) +from press.press.doctype.server.server import BaseServer from press.press.doctype.site.test_site import create_test_site from press.press.doctype.site_activity.site_activity import log_site_activity from press.press.doctype.site_backup.test_site_backup import create_test_site_backup from press.press.doctype.telegram_message.telegram_message import TelegramMessage +@patch.object(BaseServer, "_setup_agent_auth", new=Mock()) @patch.object(TelegramMessage, "enqueue", new=Mock()) @patch.object(AgentJob, "enqueue_http_request", new=Mock()) class TestBackupRecordCheck(FrappeTestCase): @@ -77,6 +79,7 @@ def test_sites_that_were_recently_activated_are_ignored(self): self.assertEqual(audit_log.status, "Success") +@patch.object(BaseServer, "_setup_agent_auth", new=Mock()) @patch.object(TelegramMessage, "enqueue", new=Mock()) @patch.object(AgentJob, "enqueue_http_request", new=Mock()) class TestOffsiteBackupCheck(FrappeTestCase): From 450103d228fd1f56abd5592189c1dab88c668e4c Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 16:54:42 +0000 Subject: [PATCH 43/59] fix(lint): Fix lint issues --- press/api/site.py | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/press/api/site.py b/press/api/site.py index 3c1019dfa14..7eaff5b8d47 100644 --- a/press/api/site.py +++ b/press/api/site.py @@ -286,6 +286,26 @@ def get_group_for_new_site_and_set_localisation_app(site, apps): return groups[0] +def _can_use_dedicated_server_plan(server: str, new_site_plan) -> bool: + if not new_site_plan.get("restrict_based_on_dedicated_server_plan", 0): + return True + + app_server_plan = frappe.db.get_value("Server", server, "plan") + + min_app_server_price_usd = new_site_plan.get( + "minimum_server_price_usd", + 0, + ) + + app_server_price_usd = frappe.db.get_value( + "Server Plan", + app_server_plan, + "price_usd", + ) + + return app_server_price_usd >= min_app_server_price_usd + + @validate_argument_types def validate_plan(server: str, site: str, new_plan: str, is_new: bool = False) -> None: if not frappe.db.exists("Site Plan", new_plan): @@ -330,6 +350,7 @@ def validate_plan(server: str, site: str, new_plan: str, is_new: bool = False) - and (is_current_plan_supported != is_product_warranty_enabled_for_plan_(new_plan)) ): quota = get_available_warranty_quota_for_server(server) + if quota.get("available") <= 0: frappe.throw( "You have exhausted the site warranty quota for this server. To increase limit, please contact support." @@ -341,14 +362,9 @@ def validate_plan(server: str, site: str, new_plan: str, is_new: bool = False) - if ( new_site_plan.get("dedicated_server_plan", 0) and frappe.db.get_value("Server", server, "team") == get_current_team() + and _can_use_dedicated_server_plan(server, new_site_plan) ): - if not new_site_plan.get("restrict_based_on_dedicated_server_plan", 0): - return - app_server_plan = frappe.db.get_value("Server", server, "plan") - min_app_server_price_usd = new_site_plan.get("minimum_server_price_usd", 0) - app_server_price_usd = frappe.db.get_value("Server Plan", app_server_plan, "price_usd") - if app_server_price_usd >= min_app_server_price_usd: - return + return if frappe.session.data.user_type == "System User": return From bd1d17af7567aeab6c7e44f645799388653c16de Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 17:37:28 +0000 Subject: [PATCH 44/59] fix(server): Fix set db healthcheck --- press/api/site.py | 99 ++++++++++++++++++++-------- press/press/doctype/server/server.py | 2 +- 2 files changed, 71 insertions(+), 30 deletions(-) diff --git a/press/api/site.py b/press/api/site.py index 7eaff5b8d47..594bc687c36 100644 --- a/press/api/site.py +++ b/press/api/site.py @@ -306,17 +306,71 @@ def _can_use_dedicated_server_plan(server: str, new_site_plan) -> bool: return app_server_price_usd >= min_app_server_price_usd -@validate_argument_types -def validate_plan(server: str, site: str, new_plan: str, is_new: bool = False) -> None: - if not frappe.db.exists("Site Plan", new_plan): - frappe.throw(f"Plan {new_plan} does not exist", frappe.DoesNotExistError) # nosemgrep +def _should_validate_warranty_change( + is_new: bool, + is_system_user: bool, + is_current_dedicated_server_plan, + is_current_plan_supported, + new_plan: str, +) -> bool: + return ( + not is_new + and not is_system_user + and is_current_dedicated_server_plan + and (is_current_plan_supported != is_product_warranty_enabled_for_plan_(new_plan)) + ) + + +def _validate_warranty_change_window(site: str): + next_warranty_change = get_next_allowed_dedicated_product_warranty_change_date(site) + + if get_datetime() < next_warranty_change: + pretty_date = format_datetime( + next_warranty_change, + "MMM d, YYYY hh:mm a", + ) - is_current_plan_supported, is_current_dedicated_server_plan = frappe.db.get_value( + frappe.throw(f"Cannot change product warranty for this site before {pretty_date}") # nosemgrep + + +def _validate_warranty_quota(server: str): + quota = get_available_warranty_quota_for_server(server) + + if quota.get("available") <= 0: + frappe.throw( + "You have exhausted the site warranty quota for this server. To increase limit, please contact support." + ) + + +def _get_current_plan_details(site: str, is_new: bool): + if is_new: + return None, None + + return frappe.db.get_value( "Site Plan", frappe.get_value("Site", site, "plan"), ["support_included", "dedicated_server_plan"], ) + +@validate_argument_types +def validate_plan( + server: str, + site: str, + new_plan: str, + is_new: bool = False, +) -> None: + if not frappe.db.exists("Site Plan", new_plan): + frappe.throw( + f"Plan {new_plan} does not exist", + frappe.DoesNotExistError, + ) # nosemgrep + + ( + is_current_plan_supported, + is_current_dedicated_server_plan, + ) = _get_current_plan_details(site, is_new) + new_site_plan = frappe.db.get_value( "Site Plan", new_plan, @@ -331,30 +385,17 @@ def validate_plan(server: str, site: str, new_plan: str, is_new: bool = False) - is_system_user = frappe.session.data.user_type == "System User" - next_warranty_change = get_next_allowed_dedicated_product_warranty_change_date(site) - - if ( - not is_new - and not is_system_user - and is_current_dedicated_server_plan - and (is_current_plan_supported != is_product_warranty_enabled_for_plan_(new_plan)) - and get_datetime() < next_warranty_change - ): - pretty_date = format_datetime(next_warranty_change, "MMM d, YYYY hh:mm a") - frappe.throw(f"Cannot change product warranty for this site before {pretty_date}") # nosemgrep - - if ( - not is_new - and not is_system_user - and is_current_dedicated_server_plan - and (is_current_plan_supported != is_product_warranty_enabled_for_plan_(new_plan)) - ): - quota = get_available_warranty_quota_for_server(server) + should_validate_warranty = _should_validate_warranty_change( + is_new, + is_system_user, + is_current_dedicated_server_plan, + is_current_plan_supported, + new_plan, + ) - if quota.get("available") <= 0: - frappe.throw( - "You have exhausted the site warranty quota for this server. To increase limit, please contact support." - ) + if should_validate_warranty: + _validate_warranty_change_window(site) + _validate_warranty_quota(server) if new_site_plan.get("price_usd", 0) > 0: return @@ -366,7 +407,7 @@ def validate_plan(server: str, site: str, new_plan: str, is_new: bool = False) - ): return - if frappe.session.data.user_type == "System User": + if is_system_user: return frappe.throw("You are not allowed to use this plan") # nosemgrep diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index 2d83a8141f0..ccaf912c266 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -2993,7 +2993,7 @@ def validate(self): self.validate_managed_database_service() def set_db_healthcheck_token(self): - if not self.db_healthcheck_token: + if not getattr(self, "db_healthcheck_token", None): self.db_healthcheck_token = frappe.generate_hash(length=64) def validate_managed_database_service(self): From 0f7c045310a06d819e6d633a65f5f9ceb10ea338 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 19:12:49 +0000 Subject: [PATCH 45/59] fix(test-audit): Fix flaky backup audit by using relative timestamps --- .cspell.json | 1569 +++++++++-------- .../database_server/database_server.py | 3 + press/tests/test_audit.py | 18 +- 3 files changed, 798 insertions(+), 792 deletions(-) diff --git a/.cspell.json b/.cspell.json index afe0e50a079..0e4a7036f14 100644 --- a/.cspell.json +++ b/.cspell.json @@ -1,787 +1,788 @@ { - "version": "0.2", - "language": "en", - "allowCompoundWords": true, - "ignorePaths": [ - "dashboard/node_modules", - "**/assets", - "*.json", - "**.jinja2", - "**.j2", - "**.service", - "**.yml", - "test_**", - "**.conf", - "requirements.txt", - "dev-requirements.txt", - "press/utils/country_timezone.py", - ".secrets.baseline", - "**go.sum" - ], - "words": [ - "2.4.6", - "Aaiun", - "Ababa", - "activites", - "Adak", - "adblockers", - "Addis", - "aditya", - "Adminstrator", - "aescts", - "afero", - "Agejt", - "aggs", - "Ajkr", - "Akbary", - "Akts", - "Åland", - "Anadyr", - "Andhra", - "ansari", - "Aqtau", - "Aqtobe", - "Araguaina", - "Arunachal", - "Asmera", - "asmfmt", - "asname", - "asrc", - "ATEXT", - "athul", - "Atikokan", - "Atka", - "atleast", - "atotto", - "Atyrau", - "auid", - "awalsh", - "awwzvf", - "aymanbagabas", - "backgound", - "Baja", - "Balamurali", - "Barthelemy", - "Barthélemy", - "Bator", - "behavior", - "behaviour", - "benbjohnson", - "BENTO", - "binlog", - "biosdevname", - "blkid", - "bofq", - "boto", - "Bouvet", - "bouy", - "buildx", - "Busingen", - "Billing", - "Cabo", - "CCONTENT", - "cellbuf", - "cellbug", - "CFWS", - "chdir", - "Chhattisgarh", - "Choibalsan", - "Chuuk", - "chsh", - "chzyer", - "cidata", - "cint", - "clamav", - "clas", - "cli", - "cloudimg", - "CMDLINE", - "CNAME", - "cnsistency", - "CODECOV", - "codespell", - "commitlint", - "Comod", - "COMPATBILITY", - "confs", - "Consolas", - "Containerised", - "coveragerc", - "cpath", - "cpcommerce", - "cpuid", - "cpus", - "creat", - "creds", - "Creston", - "Csvg", - "csvg", - "CTEXT", - "CTPBJ", - "Cuiaba", - "Cunha", - "cust", - "Dacca", - "Dadra", - "Danmarkshavn", - "Darkify", - "dateutil", - "davecgh", - "DAYOFMONTH", - "DAYOFWEEK", - "DAYOFYEAR", - "daum", - "dbgsym", - "dboptimize", - "dbserver", - "DBUS", - "dcbs", - "DCONTENT", - "ddeb", - "ddebs", - "ddl", - "dearmor", - "devscripts", - "devtmpfs", - "dffx", - "Dgzr", - "Dili", - "dmypy", - "DNOQOHHMYYI", - "dnsmasq", - "dnspython", - "dnsutils", - "doesnt", - "dont", - "DONTNEED", - "dpkg", - "dribbble", - "DSEes", - "DTEXT", - "duckdb", - "DUID", - "Dumont", - "EACCES", - "earlyoom", - "ecommerce", - "EDITMSG", - "Efate", - "efi", - "EHIKF", - "Eirunepe", - "elif", - "elts", - "emaill", - "emandate", - "Ensenada", - "EPERM", - "equivs", - "erikgeiser", - "erpdb", - "erpnext", - "erpnextcom", - "erpnextsmb", - "errgo", - "Eswatini", - "Eucla", - "euid", - "EVHT", - "execv", - "execve", - "exitst", - "Exlude", - "FADV", - "Fakaofo", - "faris", - "Faso", - "fchmod", - "fchmodat", - "fchown", - "fchownat", - "fcrestore", - "Fdvmq", - "FEFF", - "Fffphu", - "filippo", - "Fmbeo", - "Fpww", - "frappeclient", - "frappehr", - "Frappeio", - "frappeui", - "fremovexattr", - "fsetxattr", - "fstype", - "ftrace", - "ftruncate", - "Fung", - "FWUP", - "Fzqt", - "gcore", - "Gekx", - "gaierror", - "genproto", - "getdate", - "getitimer", - "gget", - "ghaction", - "ghead", - "githubusercontent", - "glfw", - "glog", - "gmxxxxcom", - "gnueabi", - "GOARCH", - "goasm", - "goccy", - "godebug", - "gofork", - "goidentity", - "gokrb", - "goleak", - "gonum", - "gopkg", - "gotool", - "Gozu", - "Gqttikk", - "grequests", - "gshadow", - "GSSAPI", - "gstin", - "gstinhide", - "gstinshow", - "gtid", - "gunicorn", - "gxzc", - "hakanensari", - "Haryana", - "hase", - "Haveli", - "hetzner", - "hdel", - "hdfs", - "hget", - "Himachal", - "honnef", - "hookpy", - "Hovd", - "hrms", - "hrtimers", - "hset", - "hsts", - "htpasswd", - "Hvyanc", - "ibdata", - "Ibhfb", - "ibtmp", - "iceber", - "ifaces", - "Ifalt", - "ifnames", - "ifnull", - "IGST", - "ikxn", - "ILIKE", - "imds", - "Incase", - "innodb", - "innoterra", - "inodes", - "inplace", - "interactjs", - "interner", - "Inuvik", - "invs", - "iour", - "iowait", - "ipaddress", - "ipdb", - "IPEYBICE", - "iputils", - "ipython", - "IRET", - "isatty", - "isin", - "isnotnull", - "istable", - "ITIMER", - "Jammu", - "jcmturner", - "jemalloc", - "Jharkhand", - "Jhuj", - "jmespath", - "JMWS", - "Jnsl", - "joomla", - "joxit", - "jscache", - "jsons", - "jstemmer", - "Jujuy", - "JZNG", - "Karnataka", - "kcontinue", - "kdhz", - "KGUJ", - "Khandyga", - "KHTML", - "Kiritimati", - "kisielk", - "Kitts", - "Kolkata", - "kontinue", - "Kralendijk", - "Kuala", - "Kvsc", - "kwarg", - "kwargs", - "Ladakh", - "Lakshadweep", - "Latrh", - "lchown", - "Leste", - "libc", - "libdevel", - "libharfbuzz", - "libpango", - "libpangocairo", - "libsm", - "libstdc", - "libx", - "libxcb", - "libxext", - "libxmuu", - "libxrender", - "Lindeman", - "llen", - "localds", - "logex", - "Longyearbyen", - "LOUAA", - "lpush", - "lqez", - "lrange", - "lremovexattr", - "lsetxattr", - "lucasb", - "Lumpur", - "luxon", - "Maarten", - "Madhya", - "MADKY", - "Mahe", - "makeprg", - "marcboeker", - "MARIADB", - "mariadbd", - "Marino", - "Marketpalce", - "mattn", - "Mayen", - "mbps", - "mccabe", - "Meghalaya", - "Menlo", - "mergify", - "Metlakatla", - "mhpd", - "Mhsc", - "Minh", - "missingok", - "Mizoram", - "mkdir", - "mkisofs", - "Mmckchk", - "mname", - "momentjs", - "Moresby", - "moto", - "Mpesa", - "msgprint", - "msisdn", - "Mtay", - "muieblackcat", - "Murdo", - "mxschmitt", - "myadmin", - "Mycp", - "myisam", - "mypma", - "mypy", - "mysqladmin", - "mysqld", - "mysqldb", - "Mywk", - "nach", - "Nadu", - "Nagar", - "ncdu", - "nedded", - "NEFT", - "Nera", - "netcfg", - "NFKH", - "NGROK", - "nineth", - "Nipigon", - "nistp", - "njsproj", - "nocompress", - "nofail", - "NOFORK", - "noozm", - "NOPASSWD", - "Noronha", - "Norte", - "notifempty", - "notin", - "nqhxc", - "ntfs", - "ntvs", - "Nuuk", - "nvme", - "Nxzjr", - "objx", - "Occurred", - "OCI", - "OCID", - "ocpu", - "ocpus", - "ocsp", - "Odisha", - "Ojinaga", - "Olgu", - "OLQY", - "ondismiss", - "onfail", - "oom", - "opasswd", - "OPENBLAS", - "opions", - "overriden", - "OWUVXXW", - "oxxk", - "Paasphrase", - "packagejsons", - "Pago", - "paise", - "Pangnirtung", - "paramiko", - "parentfield", - "parenttype", - "pborman", - "pckj", - "pckjs", - "Pedning", - "Pesa", - "pexpect", - "pfiles", - "pflag", - "Pfrw", - "pgrep", - "phpmyadmin", - "pids", - "Pjpw", - "pkgs", - "pmadb", - "pmezard", - "Pmirojx", - "Pohnpei", - "popperjs", - "pppconfig", - "pppoeconf", - "pprof", - "Pradesh", - "primarys", - "prm", - "probability", - "proces", - "procs", - "proot", - "promql", - "protoc", - "psync", - "ptype", - "Puducherry", - "Punta", - "Pushkarev", - "pycache", - "pycups", - "pyngrok", - "pypika", - "pypr", - "pypr", - "pyproject", - "pypt", - "pyspy", - "PYTHONUNBUFFERED", - "pytz", - "pyunit", - "Pziu", - "QCONTENT", - "Qostanay", - "Qrcode", - "qrcode", - "QTEXT", - "Qyzylorda", - "rcfile", - "rdata", - "rdatatype", - "recognise", - "rediffmail", - "redisearch", - "referer", - "Regs", - "Releas", - "removexattr", - "reqd", - "Rerunnability", - "rerunnable", - "Réunion", - "Rhiv", - "Rhxk", - "Rica", - "RIOHXQEHM", - "Rioja", - "rivo", - "rname", - "rnyq", - "rogpeppe", - "rootfs", - "rpush", - "rrset", - "Rsya", - "rtype", - "rutwikhdev", - "ruzy", - "rzgre", - "saas", - "sadd", - "sahilm", - "Santo", - "saurabh", - "sbool", - "Scoresbysund", - "sda", - "sdext", - "sdf", - "sdg", - "sdist", - "sdomain", - "secho", - "Segoe", - "segs", - "seperate", - "serializability", - "setxattr", - "shadrak", - "shuralyov", - "signup", - "sina", - "SLXVDP", - "slugified", - "smembers", - "SNUBA", - "snuba", - "socketio", - "softirq", - "somes", - "sonner", - "spamd", - "splited", - "sprintf", - "squashfs", - "Srednekolymsk", + "version": "0.2", + "language": "en", + "allowCompoundWords": true, + "ignorePaths": [ + "dashboard/node_modules", + "**/assets", + "*.json", + "**.jinja2", + "**.j2", + "**.service", + "**.yml", + "test_**", + "**.conf", + "requirements.txt", + "dev-requirements.txt", + "press/utils/country_timezone.py", + ".secrets.baseline", + "**go.sum" + ], + "words": [ + "2.4.6", + "Aaiun", + "Ababa", + "activites", + "Adak", + "adblockers", + "Addis", + "aditya", + "Adminstrator", + "aescts", + "afero", + "Agejt", + "aggs", + "Ajkr", + "Akbary", + "Akts", + "Åland", + "Anadyr", + "Andhra", + "ansari", + "Aqtau", + "Aqtobe", + "Araguaina", + "Arunachal", + "Asmera", + "asmfmt", + "asname", + "asrc", + "ATEXT", + "athul", + "Atikokan", + "Atka", + "atleast", + "atotto", + "Atyrau", + "auid", + "awalsh", + "awwzvf", + "aymanbagabas", + "backgound", + "Baja", + "Balamurali", + "Barthelemy", + "Barthélemy", + "Bator", + "behavior", + "behaviour", + "benbjohnson", + "BENTO", + "binlog", + "biosdevname", + "blkid", + "bofq", + "boto", + "Bouvet", + "bouy", + "buildx", + "Busingen", + "Billing", + "Cabo", + "CCONTENT", + "cellbuf", + "cellbug", + "CFWS", + "chdir", + "Chhattisgarh", + "Choibalsan", + "Chuuk", + "chsh", + "chzyer", + "cidata", + "cint", + "clamav", + "clas", + "cli", + "cloudimg", + "CMDLINE", + "CNAME", + "cnsistency", + "CODECOV", + "codespell", + "commitlint", + "Comod", + "COMPATBILITY", + "confs", + "Consolas", + "Containerised", + "coveragerc", + "cpath", + "cpcommerce", + "cpuid", + "cpus", + "creat", + "creds", + "Creston", + "Csvg", + "csvg", + "CTEXT", + "CTPBJ", + "Cuiaba", + "Cunha", + "cust", + "Dacca", + "Dadra", + "Danmarkshavn", + "Darkify", + "dateutil", + "davecgh", + "DAYOFMONTH", + "DAYOFWEEK", + "DAYOFYEAR", + "daum", + "dbgsym", + "dboptimize", + "dbserver", + "DBUS", + "dcbs", + "DCONTENT", + "ddeb", + "ddebs", + "ddl", + "dearmor", + "devscripts", + "devtmpfs", + "dffx", + "Dgzr", + "Dili", + "dmypy", + "DNOQOHHMYYI", + "dnsmasq", + "dnspython", + "dnsutils", + "doesnt", + "dont", + "DONTNEED", + "dpkg", + "dribbble", + "DSEes", + "DTEXT", + "duckdb", + "DUID", + "Dumont", + "EACCES", + "earlyoom", + "ecommerce", + "EDITMSG", + "Efate", + "efi", + "EHIKF", + "Eirunepe", + "elif", + "elts", + "emaill", + "emandate", + "Ensenada", + "EPERM", + "equivs", + "erikgeiser", + "erpdb", + "erpnext", + "erpnextcom", + "erpnextsmb", + "errgo", + "Eswatini", + "Eucla", + "euid", + "EVHT", + "execv", + "execve", + "exitst", + "Exlude", + "FADV", + "Fakaofo", + "faris", + "Faso", + "fchmod", + "fchmodat", + "fchown", + "fchownat", + "fcrestore", + "Fdvmq", + "FEFF", + "Fffphu", + "filippo", + "Fmbeo", + "Fpww", + "frappeclient", + "frappehr", + "Frappeio", + "frappeui", + "fremovexattr", + "fsetxattr", + "fstype", + "ftrace", + "ftruncate", + "Fung", + "FWUP", + "Fzqt", + "gcore", + "Gekx", + "gaierror", + "genproto", + "getdate", + "getitimer", + "gget", + "ghaction", + "ghead", + "githubusercontent", + "glfw", + "glog", + "gmxxxxcom", + "gnueabi", + "GOARCH", + "goasm", + "goccy", + "godebug", + "gofork", + "goidentity", + "gokrb", + "goleak", + "gonum", + "gopkg", + "gotool", + "Gozu", + "Gqttikk", + "grequests", + "gshadow", + "GSSAPI", + "gstin", + "gstinhide", + "gstinshow", + "gtid", + "gunicorn", + "gxzc", + "hakanensari", + "Haryana", + "hase", + "Haveli", + "hetzner", + "hdel", + "hdfs", + "hget", + "Himachal", + "honnef", + "hookpy", + "Hovd", + "hrms", + "hrtimers", + "hset", + "hsts", + "htpasswd", + "Hvyanc", + "ibdata", + "Ibhfb", + "ibtmp", + "iceber", + "ifaces", + "Ifalt", + "ifnames", + "ifnull", + "IGST", + "ikxn", + "ILIKE", + "imds", + "Incase", + "innodb", + "innoterra", + "inodes", + "inplace", + "interactjs", + "interner", + "Inuvik", + "invs", + "iour", + "iowait", + "ipaddress", + "ipdb", + "IPEYBICE", + "iputils", + "ipython", + "IRET", + "isatty", + "isin", + "isnotnull", + "istable", + "ITIMER", + "Jammu", + "jcmturner", + "jemalloc", + "Jharkhand", + "Jhuj", + "jmespath", + "JMWS", + "Jnsl", + "joomla", + "joxit", + "jscache", + "jsons", + "jstemmer", + "Jujuy", + "JZNG", + "Karnataka", + "kcontinue", + "kdhz", + "KGUJ", + "Khandyga", + "KHTML", + "Kiritimati", + "kisielk", + "Kitts", + "Kolkata", + "kontinue", + "Kralendijk", + "Kuala", + "Kvsc", + "kwarg", + "kwargs", + "Ladakh", + "Lakshadweep", + "Latrh", + "lchown", + "Leste", + "libc", + "libdevel", + "libharfbuzz", + "libpango", + "libpangocairo", + "libsm", + "libstdc", + "libx", + "libxcb", + "libxext", + "libxmuu", + "libxrender", + "Lindeman", + "llen", + "localds", + "logex", + "Longyearbyen", + "LOUAA", + "lpush", + "lqez", + "lrange", + "lremovexattr", + "lsetxattr", + "lucasb", + "Lumpur", + "luxon", + "Maarten", + "Madhya", + "MADKY", + "Mahe", + "makeprg", + "marcboeker", + "MARIADB", + "mariadbd", + "Marino", + "Marketpalce", + "mattn", + "Mayen", + "mbps", + "mccabe", + "Meghalaya", + "Menlo", + "mergify", + "Metlakatla", + "mhpd", + "Mhsc", + "Minh", + "missingok", + "Mizoram", + "mkdir", + "mkisofs", + "Mmckchk", + "mname", + "momentjs", + "Moresby", + "moto", + "Mpesa", + "msgprint", + "msisdn", + "Mtay", + "muieblackcat", + "Murdo", + "mxschmitt", + "myadmin", + "Mycp", + "myisam", + "mypma", + "mypy", + "mysqladmin", + "mysqld", + "mysqldb", + "Mywk", + "nach", + "Nadu", + "Nagar", + "ncdu", + "nedded", + "NEFT", + "Nera", + "netcfg", + "NFKH", + "NGROK", + "nineth", + "Nipigon", + "nistp", + "njsproj", + "nocompress", + "nofail", + "NOFORK", + "noozm", + "NOPASSWD", + "Noronha", + "Norte", + "notifempty", + "notin", + "nqhxc", + "ntfs", + "ntvs", + "Nuuk", + "nvme", + "Nxzjr", + "objx", + "Occurred", + "OCI", + "OCID", + "ocpu", + "ocpus", + "ocsp", + "Odisha", + "Ojinaga", + "Olgu", + "OLQY", + "ondismiss", + "onfail", + "oom", + "opasswd", + "OPENBLAS", + "opions", + "overriden", + "OWUVXXW", + "oxxk", + "Paasphrase", + "packagejsons", + "Pago", + "paise", + "Pangnirtung", + "paramiko", + "parentfield", + "parenttype", + "pborman", + "pckj", + "pckjs", + "Pedning", + "Pesa", + "pexpect", + "pfiles", + "pflag", + "Pfrw", + "pgrep", + "phpmyadmin", + "pids", + "Pjpw", + "pkgs", + "pmadb", + "pmezard", + "Pmirojx", + "Pohnpei", + "popperjs", + "pppconfig", + "pppoeconf", + "pprof", + "Pradesh", + "primarys", + "prm", + "probability", + "proces", + "procs", + "proot", + "promql", + "protoc", + "psync", + "ptype", + "Puducherry", + "Punta", + "Pushkarev", + "pycache", + "pycups", + "pyngrok", + "pypika", + "pypr", + "pypr", + "pyproject", + "pypt", + "pyspy", + "PYTHONUNBUFFERED", + "pytz", + "pyunit", + "Pziu", + "QCONTENT", + "Qostanay", + "Qrcode", + "qrcode", + "QTEXT", + "Qyzylorda", + "rcfile", + "rdata", + "rdatatype", + "recognise", + "rediffmail", + "redisearch", + "referer", + "Regs", + "Releas", + "removexattr", + "reqd", + "Rerunnability", + "rerunnable", + "Réunion", + "Rhiv", + "Rhxk", + "Rica", + "RIOHXQEHM", + "Rioja", + "rivo", + "rname", + "rnyq", + "rogpeppe", + "rootfs", + "rpush", + "rrset", + "Rsya", + "rtype", + "rutwikhdev", + "ruzy", + "rzgre", + "saas", + "sadd", + "sahilm", + "Santo", + "saurabh", + "sbool", + "Scoresbysund", + "sda", + "sdext", + "sdf", + "sdg", + "sdist", + "sdomain", + "secho", + "Segoe", + "segs", + "seperate", + "serializability", + "setxattr", + "shadrak", + "shuralyov", + "signup", + "sina", + "SLXVDP", + "slugified", + "smembers", + "SNUBA", + "snuba", + "socketio", + "softirq", + "somes", + "sonner", + "spamd", + "splited", + "sprintf", + "squashfs", + "Srednekolymsk", "srem", - "Starke", - "stdc", - "stime", - "stkpush", - "stopasgroup", - "Storge", - "stretchr", - "stripnl", - "Strnm", - "supectl", - "supervisorctl", - "supervisord", - "swapuuid", - "SYMBOLICATOR", - "symbolicator", - "synchronise", - "Syowa", - "Syrus", - "sysrq", - "tanmoy", - "tanmoysrt", - "tanxxxxxxkar", - "Telangana", - "termenv", - "Thgcy", - "tidb", - "Tiraspol", - "Tkndys", - "tldextract", - "Tmate", - "tmpfs", - "Tokelau", - "tomli", - "Tongatapu", - "TOOD", - "TOTP", - "totp", - "tqdm", - "Troso", - "TSZK", - "tupple", - "Tvyn", - "Twillio", - "udiff", - "Udxsrq", - "uefi", - "Uenf", - "Ujung", - "Ulaanbaatar", - "Ulan", - "unarchived", - "Unbilled", - "uncollectible", - "unfollow", - "unindex", - "unindexed", - "unindexing", - "uniseg", - "unlinkat", - "unparse", - "unpatch", - "unplugin", - "Unprovisioned", - "unscrub", - "unsuspended", - "Unsuspending", - "unsuspension", - "updadted", - "urandom", - "uring", - "Urville", - "USEDNS", - "Ushuaia", - "Uttar", - "Uttarakhand", - "Uzhgorod", - "vagrant", - "varkw", - "vasile", - "VBDHE", - "vcpu", - "vcpus", - "vda", - "Velho", - "venv", - "Vetur", - "vetur", - "Vevay", - "vfat", - "vimrc", - "virsh", - "virtualenv", - "Vite", - "vite", - "vitess", - "VMI", - "vmis", - "vnic", - "Vodacom", - "volid", - "vpus", - "vtprotobuf", - "vueuse", - "vxeg", - "Vzzq", - "Wazuh", - "wazuh", - "weasyprint", - "webp", - "Werkzeug", - "Winamac", - "witht", - "Wiue", - "wkhtmlto", - "WKHTMLTOPDF", - "wkhtmltox", - "Wpym", - "xampp", - "xauth", - "xcall", - "xerrors", - "xfonts", - "XHOMZ", - "xitongsys", - "xlink", - "Xpai", - "XPUT", - "Xrwmb", - "xvda", - "xvdf", - "xvdg", - "Xwgt", - "xyproto", - "Xyrw", - "Xzmq", - "Yakutat", - "Yancowinna", - "Yekq", - "Ynel", - "yxei", - "Yzuve", - "zeebo", - "zloirock", - "Zpyihv", - "ZSTD", - "Zvkq", - "botocore", - "reka", - "pydo", - "erpnextv", - "hrmsv", - "unyank", - "unyanking", - "unyanked", - "shirou", - "gopsutil", - "SSIZE", - "innobase", - "FSEG", - "XDES", - "FLST", - "pyotp", - "noopener", - "noreferrer", - "nosemgrep", - "centralise", - "PSIIO", - "journalctl", - "ptrs", - "serialised", - "setnx", - "ssiyad", - "Aradhya", - "Tripathi", - "siduck", - "prathameshkurunkar", - "Bowrna", - "vitepress", - "resolvconf", - "lsync", - "lsyncd", - "awk", - "gawk", - "picklable", - "ndarray", - "mult", - "ruleid", - "chatwoot", - "ssti" - ] + "Starke", + "stdc", + "stime", + "stkpush", + "stopasgroup", + "Storge", + "stretchr", + "stripnl", + "Strnm", + "supectl", + "supervisorctl", + "supervisord", + "swapuuid", + "SYMBOLICATOR", + "symbolicator", + "synchronise", + "Syowa", + "Syrus", + "sysrq", + "tanmoy", + "tanmoysrt", + "tanxxxxxxkar", + "Telangana", + "termenv", + "tcmalloc", + "Thgcy", + "tidb", + "Tiraspol", + "Tkndys", + "tldextract", + "Tmate", + "tmpfs", + "Tokelau", + "tomli", + "Tongatapu", + "TOOD", + "TOTP", + "totp", + "tqdm", + "Troso", + "TSZK", + "tupple", + "Tvyn", + "Twillio", + "udiff", + "Udxsrq", + "uefi", + "Uenf", + "Ujung", + "Ulaanbaatar", + "Ulan", + "unarchived", + "Unbilled", + "uncollectible", + "unfollow", + "unindex", + "unindexed", + "unindexing", + "uniseg", + "unlinkat", + "unparse", + "unpatch", + "unplugin", + "Unprovisioned", + "unscrub", + "unsuspended", + "Unsuspending", + "unsuspension", + "updadted", + "urandom", + "uring", + "Urville", + "USEDNS", + "Ushuaia", + "Uttar", + "Uttarakhand", + "Uzhgorod", + "vagrant", + "varkw", + "vasile", + "VBDHE", + "vcpu", + "vcpus", + "vda", + "Velho", + "venv", + "Vetur", + "vetur", + "Vevay", + "vfat", + "vimrc", + "virsh", + "virtualenv", + "Vite", + "vite", + "vitess", + "VMI", + "vmis", + "vnic", + "Vodacom", + "volid", + "vpus", + "vtprotobuf", + "vueuse", + "vxeg", + "Vzzq", + "Wazuh", + "wazuh", + "weasyprint", + "webp", + "Werkzeug", + "Winamac", + "witht", + "Wiue", + "wkhtmlto", + "WKHTMLTOPDF", + "wkhtmltox", + "Wpym", + "xampp", + "xauth", + "xcall", + "xerrors", + "xfonts", + "XHOMZ", + "xitongsys", + "xlink", + "Xpai", + "XPUT", + "Xrwmb", + "xvda", + "xvdf", + "xvdg", + "Xwgt", + "xyproto", + "Xyrw", + "Xzmq", + "Yakutat", + "Yancowinna", + "Yekq", + "Ynel", + "yxei", + "Yzuve", + "zeebo", + "zloirock", + "Zpyihv", + "ZSTD", + "Zvkq", + "botocore", + "reka", + "pydo", + "erpnextv", + "hrmsv", + "unyank", + "unyanking", + "unyanked", + "shirou", + "gopsutil", + "SSIZE", + "innobase", + "FSEG", + "XDES", + "FLST", + "pyotp", + "noopener", + "noreferrer", + "nosemgrep", + "centralise", + "PSIIO", + "journalctl", + "ptrs", + "serialised", + "setnx", + "ssiyad", + "Aradhya", + "Tripathi", + "siduck", + "prathameshkurunkar", + "Bowrna", + "vitepress", + "resolvconf", + "lsync", + "lsyncd", + "awk", + "gawk", + "picklable", + "ndarray", + "mult", + "ruleid", + "chatwoot", + "ssti" + ] } diff --git a/press/press/doctype/database_server/database_server.py b/press/press/doctype/database_server/database_server.py index 045b76f767f..47627653142 100644 --- a/press/press/doctype/database_server/database_server.py +++ b/press/press/doctype/database_server/database_server.py @@ -2735,6 +2735,9 @@ def process_remove_binlogs_from_indexer_agent_job_update(job: AgentJob): if job.status != "Success": return + if not job.data: + return + json_data = json.loads(job.data) binlogs_in_disk = json_data.get("unindexed_binlogs", []) frappe.db.set_value( diff --git a/press/tests/test_audit.py b/press/tests/test_audit.py index f680aa618a1..628a78d0ff7 100644 --- a/press/tests/test_audit.py +++ b/press/tests/test_audit.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import timedelta from unittest.mock import Mock, patch import frappe @@ -9,14 +9,12 @@ from press.press.doctype.press_settings.test_press_settings import ( create_test_press_settings, ) -from press.press.doctype.server.server import BaseServer from press.press.doctype.site.test_site import create_test_site from press.press.doctype.site_activity.site_activity import log_site_activity from press.press.doctype.site_backup.test_site_backup import create_test_site_backup from press.press.doctype.telegram_message.telegram_message import TelegramMessage -@patch.object(BaseServer, "_setup_agent_auth", new=Mock()) @patch.object(TelegramMessage, "enqueue", new=Mock()) @patch.object(AgentJob, "enqueue_http_request", new=Mock()) class TestBackupRecordCheck(FrappeTestCase): @@ -26,15 +24,19 @@ def tearDown(self): def setUp(self): super().setUp() - self.yesterday = frappe.utils.now_datetime().date() - timedelta(days=1) - self._2_hrs_before_yesterday = datetime.combine(self.yesterday, datetime.min.time()) - timedelta( - hours=2 - ) + # Anchoring to exactly 'now' prevents the backup age from shifting + # depending on what hour of the day the CI pipeline is executed. + now = frappe.utils.now_datetime() + self._2_hrs_before_yesterday = now - timedelta(hours=26) + self.yesterday = now - timedelta(days=1) def test_audit_will_fail_if_backup_older_than_interval(self): create_test_press_settings() site = create_test_site(creation=self._2_hrs_before_yesterday) + + # Backup created 25 hours ago (outside 24-hour interval) create_test_site_backup(site.name, creation=self._2_hrs_before_yesterday + timedelta(hours=1)) + BackupRecordCheck() audit_log = frappe.get_last_doc("Audit Log", {"audit_type": BackupRecordCheck.audit_type}) self.assertEqual(audit_log.status, "Failure") @@ -43,6 +45,7 @@ def test_audit_succeeds_when_backup_in_interval_exists(self): create_test_press_settings() site = create_test_site(creation=self._2_hrs_before_yesterday) + # Backup created 23 hours ago (inside 24-hour interval) create_test_site_backup( site.name, creation=self._2_hrs_before_yesterday + timedelta(hours=3), @@ -79,7 +82,6 @@ def test_sites_that_were_recently_activated_are_ignored(self): self.assertEqual(audit_log.status, "Success") -@patch.object(BaseServer, "_setup_agent_auth", new=Mock()) @patch.object(TelegramMessage, "enqueue", new=Mock()) @patch.object(AgentJob, "enqueue_http_request", new=Mock()) class TestOffsiteBackupCheck(FrappeTestCase): From 29deaceff3240dde90d76ac4dffbaefc6d024c5e Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 18 May 2026 19:25:50 +0000 Subject: [PATCH 46/59] revert(test-audit): Revert flaky test_audit changes --- press/tests/test_audit.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/press/tests/test_audit.py b/press/tests/test_audit.py index 628a78d0ff7..3c1e9dde583 100644 --- a/press/tests/test_audit.py +++ b/press/tests/test_audit.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import datetime, timedelta from unittest.mock import Mock, patch import frappe @@ -24,19 +24,15 @@ def tearDown(self): def setUp(self): super().setUp() - # Anchoring to exactly 'now' prevents the backup age from shifting - # depending on what hour of the day the CI pipeline is executed. - now = frappe.utils.now_datetime() - self._2_hrs_before_yesterday = now - timedelta(hours=26) - self.yesterday = now - timedelta(days=1) + self.yesterday = frappe.utils.now_datetime().date() - timedelta(days=1) + self._2_hrs_before_yesterday = datetime.combine(self.yesterday, datetime.min.time()) - timedelta( + hours=2 + ) def test_audit_will_fail_if_backup_older_than_interval(self): create_test_press_settings() site = create_test_site(creation=self._2_hrs_before_yesterday) - - # Backup created 25 hours ago (outside 24-hour interval) create_test_site_backup(site.name, creation=self._2_hrs_before_yesterday + timedelta(hours=1)) - BackupRecordCheck() audit_log = frappe.get_last_doc("Audit Log", {"audit_type": BackupRecordCheck.audit_type}) self.assertEqual(audit_log.status, "Failure") @@ -45,7 +41,6 @@ def test_audit_succeeds_when_backup_in_interval_exists(self): create_test_press_settings() site = create_test_site(creation=self._2_hrs_before_yesterday) - # Backup created 23 hours ago (inside 24-hour interval) create_test_site_backup( site.name, creation=self._2_hrs_before_yesterday + timedelta(hours=3), From 1d07e4f786f198d02db900bd7202628c139ecdf3 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sun, 24 May 2026 14:23:13 +0000 Subject: [PATCH 47/59] refactor(agent): Use HS256 and hand off retry and regenerate to agent --- .cspell.json | 17 +- press/agent.py | 91 +++--- press/api/agent_auth.py | 50 +++- press/api/callbacks.py | 40 ++- press/api/monitoring.py | 5 +- press/api/server.py | 4 +- press/api/tests/test_agent_auth.py | 20 +- press/api/tests/test_callbacks.py | 59 ++-- press/hooks.py | 2 - press/playbooks/roles/agent/tasks/main.yml | 2 +- .../agent_monitoring_setup/tasks/main.yml | 2 +- .../roles/setup_agent_auth/tasks/main.yml | 13 +- press/press/doctype/agent_auth/__init__.py | 0 press/press/doctype/agent_auth/agent_auth.js | 8 - .../press/doctype/agent_auth/agent_auth.json | 94 ------ press/press/doctype/agent_auth/agent_auth.py | 90 ------ .../doctype/agent_auth/test_agent_auth.py | 42 --- press/press/doctype/agent_job/agent_job.py | 130 +++++---- .../press/doctype/agent_job/test_agent_job.py | 52 ---- .../analytics_server/analytics_server.js | 26 +- .../analytics_server/analytics_server.json | 49 +++- .../analytics_server/analytics_server.py | 6 + .../database_server/database_server.js | 6 +- .../database_server/database_server.json | 174 +++++++---- .../database_server/database_server.py | 17 +- .../database_server/test_database_server.py | 52 ---- press/press/doctype/log_server/log_server.js | 26 +- .../press/doctype/log_server/log_server.json | 45 ++- press/press/doctype/log_server/log_server.py | 6 + .../doctype/monitor_server/monitor_server.js | 26 +- .../monitor_server/monitor_server.json | 55 +++- .../doctype/monitor_server/monitor_server.py | 6 + press/press/doctype/nat_server/nat_server.js | 38 +-- .../press/doctype/nat_server/nat_server.json | 39 ++- press/press/doctype/nat_server/nat_server.py | 6 + .../press_settings/press_settings.json | 271 +++++++++++++----- .../doctype/press_settings/press_settings.py | 4 +- .../doctype/proxy_server/proxy_server.js | 54 ++-- .../doctype/proxy_server/proxy_server.json | 53 +++- .../doctype/proxy_server/proxy_server.py | 14 +- .../doctype/proxy_server/test_proxy_server.py | 52 ---- .../registry_server/registry_server.js | 32 ++- .../registry_server/registry_server.json | 68 ++++- .../registry_server/registry_server.py | 6 + press/press/doctype/server/server.js | 6 +- press/press/doctype/server/server.json | 53 ++-- press/press/doctype/server/server.py | 139 +++------ press/press/doctype/server/test_server.py | 136 --------- .../doctype/trace_server/trace_server.json | 49 +++- .../doctype/trace_server/trace_server.py | 2 + press/tests/test_agent.py | 180 ------------ 51 files changed, 1114 insertions(+), 1303 deletions(-) delete mode 100644 press/press/doctype/agent_auth/__init__.py delete mode 100644 press/press/doctype/agent_auth/agent_auth.js delete mode 100644 press/press/doctype/agent_auth/agent_auth.json delete mode 100644 press/press/doctype/agent_auth/agent_auth.py delete mode 100644 press/press/doctype/agent_auth/test_agent_auth.py diff --git a/.cspell.json b/.cspell.json index 0e4a7036f14..60f15e77e5a 100644 --- a/.cspell.json +++ b/.cspell.json @@ -16,7 +16,8 @@ "dev-requirements.txt", "press/utils/country_timezone.py", ".secrets.baseline", - "**go.sum" + "**go.sum", + "libs/**" ], "words": [ "2.4.6", @@ -593,7 +594,6 @@ "sprintf", "squashfs", "Srednekolymsk", - "srem", "Starke", "stdc", "stime", @@ -616,9 +616,9 @@ "tanmoy", "tanmoysrt", "tanxxxxxxkar", + "tcmalloc", "Telangana", "termenv", - "tcmalloc", "Thgcy", "tidb", "Tiraspol", @@ -783,6 +783,15 @@ "mult", "ruleid", "chatwoot", - "ssti" + "ssti", + "tcmalloc", + "libncurses", + "libncursesw", + "DWITHOUT", + "wextra", + "SONAME", + "decommitted", + "libgnutls", + "UDF" ] } diff --git a/press/agent.py b/press/agent.py index 16265338999..f1ff4797501 100644 --- a/press/agent.py +++ b/press/agent.py @@ -3,19 +3,17 @@ from __future__ import annotations import _io # type: ignore -import base64 import json import os import re -import time from contextlib import suppress from datetime import date from typing import TYPE_CHECKING, Any, Literal import frappe import frappe.utils +import jwt import requests -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from frappe.utils.password import get_decrypted_password from requests.exceptions import HTTPError @@ -29,7 +27,6 @@ if TYPE_CHECKING: from io import BufferedReader - from press.press.doctype.agent_auth.agent_auth import AgentAuth from press.press.doctype.agent_job.agent_job import AgentJob from press.press.doctype.app_patch.app_patch import AgentPatchConfig, AppPatch from press.press.doctype.bench.bench import Bench @@ -981,70 +978,40 @@ def get_agent_public_key(self): return public_key - def get_regenerate_public_key(self): - key = f"{self.server}_regenerate_public_key" + def get_secret(self): + key = "agent_auth_secret" - regenerate_key = frappe.cache().get_value(key) - if not regenerate_key: - agent_auth: AgentAuth = frappe.get_doc( - "Agent Auth", - self.server, - ) - - if agent_auth.regenerate_public_key: - agent_auth.regenerate_public_key = None - agent_auth.save(ignore_permissions=True) - - return None - - return regenerate_key + secret = frappe.cache().get_value(key) + if not secret: + press_settings = frappe.get_single("Press Settings") + secret = press_settings.get_password("secret") - def _is_token_verified(self, public_keys, signature, payload_bytes): - from cryptography.exceptions import InvalidSignature + if not secret: + raise ValueError("Agent auth secret not configured") - for key in public_keys: - try: - public_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(key)) + frappe.cache().set_value(key, secret) - public_key.verify(signature, payload_bytes) - - return True - - except (InvalidSignature, ValueError, TypeError): - pass - - return False + return secret def _verify_request_token(self, token: str): - parts = token.split(".") - if len(parts) != 2: - raise ValueError("Malformed token") - - payload_b64, signature_b64 = parts - - payload_bytes = base64.urlsafe_b64decode(payload_b64 + "==") - signature = base64.urlsafe_b64decode(signature_b64 + "==") + secret = self.get_secret() - public_keys = [] - - primary_key = self.get_agent_public_key() - if primary_key: - public_keys.append(primary_key) - - regenerate_key = self.get_regenerate_public_key() - if regenerate_key: - public_keys.append(regenerate_key) - - if not public_keys: - raise ValueError("No public keys available for verification") - - if not self._is_token_verified(public_keys, signature, payload_bytes): - raise ValueError("Invalid token signature") + try: + payload = jwt.decode( + token, + secret, + algorithms=["HS256"], + options={ + "require": ["exp", "server", "jti"], + }, + ) + print(payload) - payload = json.loads(payload_bytes) + except jwt.ExpiredSignatureError as err: + raise ValueError("Token expired") from err - if payload["exp"] < (time.time() - 60): - raise ValueError("Token expired") + except jwt.InvalidTokenError as err: + raise ValueError("Invalid token") from err if payload["server"] != self.server: raise ValueError("Invalid server") @@ -1378,6 +1345,12 @@ def fetch_bench_status(self, bench): def get_snapshot(self, bench: str): return self.get(f"process-snapshot/{bench}") + def enable_feature_flag(self): + return self.post("server/feature/enable") + + def disable_feature_flag(self): + return self.post("server/feature/disable") + def run_after_migrate_steps(self, site): data = { "admin_password": site.get_password("admin_password"), diff --git a/press/api/agent_auth.py b/press/api/agent_auth.py index c99df843a24..0d0905d42f5 100644 --- a/press/api/agent_auth.py +++ b/press/api/agent_auth.py @@ -1,13 +1,61 @@ +import base64 +import json +from typing import TYPE_CHECKING + import frappe +from frappe.rate_limiter import rate_limit from press.agent import Agent +if TYPE_CHECKING: + from press.press.doctype.server.server import BaseServer + + +def extract_server_from_token(token: str): + try: + parts = token.split(".") + + if len(parts) != 3: + return None + + payload_b64 = parts[1] + + # fix padding + payload_b64 += "=" * (-len(payload_b64) % 4) + + payload = json.loads(base64.urlsafe_b64decode(payload_b64)) + + return payload.get("server"), payload.get("server_type") + + except Exception: + return None + -def verify_agent(server: str): +def verify_agent(): agent_token = frappe.request.headers.get("X-Agent-Token") if not agent_token: frappe.throw_permission_error() + token_data = extract_server_from_token(agent_token) + + if not token_data: + frappe.throw_permission_error() + + server, server_type = token_data + agent = Agent(server) agent.extract_and_verify_token(agent_token) + + return server, server_type + + +@frappe.whitelist(allow_guest=True) +@rate_limit(limit=10, seconds=60) +def regenerate_token(): + server, server_type = verify_agent() + + doc: BaseServer = frappe.get_doc(server_type, server) + + secret = doc._generate_secret() + return doc.sign_agent_token(secret) diff --git a/press/api/callbacks.py b/press/api/callbacks.py index 47de6bac36e..2f8fdf9a7ed 100644 --- a/press/api/callbacks.py +++ b/press/api/callbacks.py @@ -4,13 +4,14 @@ import ipaddress import json +from typing import Any import frappe from frappe.rate_limiter import rate_limit from press.agent import Agent from press.api.agent_auth import verify_agent -from press.press.doctype.agent_job.agent_job import handle_polled_job +from press.press.doctype.agent_job.agent_job import handle_polled_job, retry_undelivered_jobs from press.utils import log_error @@ -115,7 +116,7 @@ def callback(job_id: str | None = None): if not server: frappe.throw("Not permitted", frappe.ValidationError) - verify_agent(server) + verify_agent() job = verify_job_id(server, job_id) if not job: @@ -126,18 +127,13 @@ def callback(job_id: str | None = None): @frappe.whitelist(allow_guest=True) @rate_limit(limit=500, seconds=60) -def update_job(job, server): - flag = frappe.db.get_single_value("Press Settings", "push_feature") - if not flag: - return - +def update_job(job: str) -> None: if not job: return - verify_agent(server) + server, _ = verify_agent() - if isinstance(job, str): - job = json.loads(job) + parsed_job: dict[str, Any] = json.loads(job) job_doc = frappe.get_value( "Agent Job", @@ -148,15 +144,29 @@ def update_job(job, server): "callback_failure_count", "job_type", ], - filters={"job_id": job["id"], "server": server}, + filters={"job_id": parsed_job["id"], "server": server}, as_dict=True, ) + if not job_doc: return - frappe.enqueue( - handle_polled_job, - queue="short", - polled_job=job, + handle_polled_job( + polled_job=parsed_job, job=job_doc, + raise_callback_exception=True, + ) + + +@frappe.whitelist(allow_guest=True) +def retry_undelivered(): + server, server_type = verify_agent() + + server_obj = frappe._dict( + { + "server": server, + "server_type": server_type, + } ) + + retry_undelivered_jobs(server_obj, use_exponential_backoff=False, use_queue_protection=True) diff --git a/press/api/monitoring.py b/press/api/monitoring.py index a74ce261937..8faef742b18 100644 --- a/press/api/monitoring.py +++ b/press/api/monitoring.py @@ -7,7 +7,6 @@ import frappe from frappe.rate_limiter import rate_limit -from press.api.agent_auth import verify_agent from press.exceptions import AlertRuleNotEnabled from press.press.doctype.monitor_server.monitor_server import get_monitor_server_ips from press.utils import log_error, servers_using_alternative_port_for_communication @@ -160,9 +159,7 @@ def get_targets_method_rate_limit() -> int: @frappe.whitelist(allow_guest=True) @rate_limit(limit=get_targets_method_rate_limit, seconds=MONITORING_ENDPOINT_RATE_LIMIT_WINDOW_SECONDS) -def targets(server: str, token: str | None = None): - verify_agent(server) - +def targets(token: str | None = None): if not token: frappe.throw_permission_error() monitor_token = frappe.db.get_single_value("Press Settings", "monitor_token", cache=True) diff --git a/press/api/server.py b/press/api/server.py index dbb2de7dd80..fe02eb33c2b 100644 --- a/press/api/server.py +++ b/press/api/server.py @@ -975,7 +975,7 @@ def rename(name, title): @frappe.whitelist(allow_guest=True) -def benches_are_idle(server: str) -> None: +def benches_are_idle() -> None: """Shut down the secondary server if all benches are idle. This function is only triggered by secondary servers: @@ -984,7 +984,7 @@ def benches_are_idle(server: str) -> None: current_user = frappe.session.user - verify_agent(server) + server, _ = verify_agent() primary_server, is_server_scaled_up = frappe.db.get_value( "Server", {"secondary_server": server}, ["name", "scaled_up"] diff --git a/press/api/tests/test_agent_auth.py b/press/api/tests/test_agent_auth.py index 2df857e06ea..a57f9c968b8 100644 --- a/press/api/tests/test_agent_auth.py +++ b/press/api/tests/test_agent_auth.py @@ -16,17 +16,31 @@ def test_verify_agent_throws_without_token(self): self.assertRaises( frappe.PermissionError, verify_agent, - "test-server", ) + @patch("press.api.agent_auth.extract_server_from_token") @patch("press.api.agent_auth.Agent") - def test_verify_agent_calls_extract_and_verify_token(self, mock_agent): + def test_verify_agent_calls_extract_and_verify_token( + self, + mock_agent, + mock_extract_server, + ): + mock_extract_server.return_value = ( + "test-server", + "Server", + ) + mock_instance = Mock() mock_agent.return_value = mock_instance frappe.local.request = frappe._dict({"headers": {"X-Agent-Token": "test-token"}}) - verify_agent("test-server") + server, server_type = verify_agent() + + self.assertEqual(server, "test-server") + self.assertEqual(server_type, "Server") + + mock_extract_server.assert_called_once_with("test-token") mock_agent.assert_called_once_with("test-server") diff --git a/press/api/tests/test_callbacks.py b/press/api/tests/test_callbacks.py index 62772b58dda..137183274d0 100644 --- a/press/api/tests/test_callbacks.py +++ b/press/api/tests/test_callbacks.py @@ -3,36 +3,33 @@ import frappe -from press.api.callbacks import update_job +from press.api.callbacks import ( + retry_undelivered, + update_job, +) -class TestUpdateJob(TestCase): +class TestCallbacks(TestCase): def tearDown(self): frappe.db.rollback() - @patch("press.api.callbacks.verify_agent") - def test_update_job_returns_when_feature_flag_disabled(self, _mock_verify): - with patch("frappe.db.get_single_value", return_value=0): - result = update_job(job={"id": "123"}, server="test-server") - - self.assertIsNone(result) - - @patch("press.api.callbacks.verify_agent") - def test_update_job_returns_when_job_is_missing(self, _mock_verify): - with patch("frappe.db.get_single_value", return_value=1): - result = update_job(job=None, server="test-server") - - self.assertIsNone(result) + def test_update_job_returns_when_job_missing(self): + self.assertIsNone(update_job(job=None)) + @patch("press.api.callbacks.handle_polled_job") @patch("press.api.callbacks.verify_agent") - @patch("frappe.enqueue") @patch("frappe.get_value") - def test_update_job_enqueues_handle_polled_job( + def test_update_job_calls_handle_polled_job( self, mock_get_value, - mock_enqueue, - _mock_verify, + mock_verify, + mock_handle_polled_job, ): + mock_verify.return_value = ( + "test-server", + "Server", + ) + mock_get_value.return_value = { "name": "job-1", "job_id": "123", @@ -41,10 +38,22 @@ def test_update_job_enqueues_handle_polled_job( "job_type": "Deploy", } - with patch("frappe.db.get_single_value", return_value=1): - update_job( - job={"id": "123"}, - server="test-server", - ) + update_job(job={"id": "123"}) + + mock_handle_polled_job.assert_called_once() + + @patch("press.api.callbacks.retry_undelivered_jobs") + @patch("press.api.callbacks.verify_agent") + def test_retry_undelivered( + self, + mock_verify, + mock_retry_jobs, + ): + mock_verify.return_value = ( + "test-server", + "Server", + ) + + retry_undelivered() - mock_enqueue.assert_called_once() + mock_retry_jobs.assert_called_once() diff --git a/press/hooks.py b/press/hooks.py index a7c82a7154a..2e773ec68da 100644 --- a/press/hooks.py +++ b/press/hooks.py @@ -198,7 +198,6 @@ scheduler_events = { "weekly_long": ["press.press.doctype.marketplace_app.events.auto_review_for_missing_steps"], "daily": [ - "press.press.doctype.agent_auth.agent_auth.regenerate_token", "press.experimental.doctype.referral_bonus.referral_bonus.credit_referral_bonuses", "press.press.doctype.log_counter.log_counter.record_counts", "press.press.doctype.incident.incident.notify_ignored_servers", @@ -358,7 +357,6 @@ "press.workflow_engine.doctype.press_workflow.press_workflow.retry_workflow_callbacks", ], "* * * * *": [ - "press.press.doctype.agent_job.agent_job.retry_poll", "press.press.doctype.virtual_disk_snapshot.virtual_disk_snapshot.sync_physical_backup_snapshots", "press.workflow_engine.doctype.press_workflow_task.press_workflow_task.retry_tasks", "press.press.doctype.deploy_candidate_build.deploy_candidate_build.run_scheduled_builds", diff --git a/press/playbooks/roles/agent/tasks/main.yml b/press/playbooks/roles/agent/tasks/main.yml index 416d20072bc..7b878c459b0 100644 --- a/press/playbooks/roles/agent/tasks/main.yml +++ b/press/playbooks/roles/agent/tasks/main.yml @@ -21,7 +21,7 @@ - name: Generate Agent Configuration File become: yes become_user: frappe - command: '/home/frappe/agent/env/bin/agent setup config --name {{ server }} --workers {{ workers }} {% if proxy_ip is defined and proxy_ip is truthy %}--proxy-ip {{ proxy_ip }}{% endif %} {% if agent_sentry_dsn is defined and agent_sentry_dsn is truthy %}--sentry-dsn {{ agent_sentry_dsn }}{% endif %}' + command: '/home/frappe/agent/env/bin/agent setup config --name {{ server }} --workers {{ workers }} {% if proxy_ip is defined and proxy_ip is truthy %}--proxy-ip {{ proxy_ip }}{% endif %} {% if agent_sentry_dsn is defined and agent_sentry_dsn is truthy %}--sentry-dsn {{ agent_sentry_dsn }}{% endif %} {% if agent_token is defined and agent_token is truthy %}--agent-token {{ agent_token }}{% endif %}' args: chdir: /home/frappe/agent diff --git a/press/playbooks/roles/agent_monitoring_setup/tasks/main.yml b/press/playbooks/roles/agent_monitoring_setup/tasks/main.yml index b285cab184b..dba77b9cb6f 100644 --- a/press/playbooks/roles/agent_monitoring_setup/tasks/main.yml +++ b/press/playbooks/roles/agent_monitoring_setup/tasks/main.yml @@ -21,7 +21,7 @@ - name: Generate Agent Configuration File become: yes become_user: frappe - command: '/home/frappe/agent/env/bin/agent setup config --name {{ server }} --workers {{ workers }} {% if proxy_ip is defined and proxy_ip is truthy %}--proxy-ip {{ proxy_ip }}{% endif %} {% if agent_sentry_dsn is defined and agent_sentry_dsn is truthy %}--sentry-dsn {{ agent_sentry_dsn }}{% endif %}' + command: '/home/frappe/agent/env/bin/agent setup config --name {{ server }} --workers {{ workers }} {% if proxy_ip is defined and proxy_ip is truthy %}--proxy-ip {{ proxy_ip }}{% endif %} {% if agent_sentry_dsn is defined and agent_sentry_dsn is truthy %}--sentry-dsn {{ agent_sentry_dsn }}{% endif %} {% if agent_token is defined and agent_token is truthy %}--agent-token {{ agent_token }}{% endif %}' args: chdir: /home/frappe/agent diff --git a/press/playbooks/roles/setup_agent_auth/tasks/main.yml b/press/playbooks/roles/setup_agent_auth/tasks/main.yml index bd914a0f743..c4b319a3e50 100644 --- a/press/playbooks/roles/setup_agent_auth/tasks/main.yml +++ b/press/playbooks/roles/setup_agent_auth/tasks/main.yml @@ -1,6 +1,9 @@ --- -- name: Write agent token - copy: - content: "{{ agent_token }}" - dest: "/home/frappe/agent/agent.token" - mode: '0600' +- name: Update agent token + become: yes + become_user: frappe + command: > + /home/frappe/agent/env/bin/agent setup agent-token + --agent-token {{ agent_token }} + args: + chdir: /home/frappe/agent diff --git a/press/press/doctype/agent_auth/__init__.py b/press/press/doctype/agent_auth/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/press/press/doctype/agent_auth/agent_auth.js b/press/press/doctype/agent_auth/agent_auth.js deleted file mode 100644 index 1310b1c5dde..00000000000 --- a/press/press/doctype/agent_auth/agent_auth.js +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2026, Frappe and contributors -// For license information, please see license.txt - -// frappe.ui.form.on("Agent Auth", { -// refresh(frm) { - -// }, -// }); diff --git a/press/press/doctype/agent_auth/agent_auth.json b/press/press/doctype/agent_auth/agent_auth.json deleted file mode 100644 index 00caaf098a9..00000000000 --- a/press/press/doctype/agent_auth/agent_auth.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "actions": [], - "allow_rename": 1, - "autoname": "field:server", - "creation": "2026-05-08 20:05:51.848511", - "doctype": "DocType", - "engine": "InnoDB", - "field_order": [ - "section_break_loa1", - "server", - "server_type", - "public_key", - "regenerate_public_key", - "is_agent_auth_setup", - "column_break_dlmn", - "expires_in" - ], - "fields": [ - { - "fieldname": "section_break_loa1", - "fieldtype": "Section Break", - "label": "Agent Auth" - }, - { - "fieldname": "server_type", - "fieldtype": "Data", - "label": "Server Type", - "read_only": 1 - }, - { - "default": "0", - "fieldname": "is_agent_auth_setup", - "fieldtype": "Check", - "label": "Is Agent Auth Setup", - "read_only": 1 - }, - { - "fieldname": "expires_in", - "fieldtype": "Datetime", - "label": "Expires In", - "read_only": 1 - }, - { - "fieldname": "public_key", - "fieldtype": "Data", - "label": "Public Key", - "read_only": 1 - }, - { - "fieldname": "column_break_dlmn", - "fieldtype": "Column Break" - }, - { - "fieldname": "server", - "fieldtype": "Data", - "label": "Server", - "read_only": 1, - "unique": 1 - }, - { - "fieldname": "regenerate_public_key", - "fieldtype": "Data", - "label": "Regenerate Public Key", - "read_only": 1 - } - ], - "grid_page_length": 50, - "index_web_pages_for_search": 1, - "links": [], - "modified": "2026-05-08 23:59:06.513841", - "modified_by": "Administrator", - "module": "Press", - "name": "Agent Auth", - "naming_rule": "By fieldname", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "row_format": "Dynamic", - "sort_field": "creation", - "sort_order": "DESC", - "states": [] -} diff --git a/press/press/doctype/agent_auth/agent_auth.py b/press/press/doctype/agent_auth/agent_auth.py deleted file mode 100644 index ade87ab7ef2..00000000000 --- a/press/press/doctype/agent_auth/agent_auth.py +++ /dev/null @@ -1,90 +0,0 @@ -# Copyright (c) 2026, Frappe and contributors -# For license information, please see license.txt - -import datetime -from typing import TYPE_CHECKING - -import frappe -from frappe.model.document import Document - -if TYPE_CHECKING: - from press.press.doctype.server.server import BaseServer - - -class AgentAuth(Document): - # begin: auto-generated types - # This code is auto-generated. Do not modify anything in this block. - - from typing import TYPE_CHECKING - - if TYPE_CHECKING: - from frappe.types import DF - - expires_in: DF.Datetime | None - is_agent_auth_setup: DF.Check - public_key: DF.Data | None - regenerate_public_key: DF.Data | None - server: DF.Data | None - server_type: DF.Data | None - # end: auto-generated types - - def _regenerate_token(self): - if not self.is_agent_auth_setup: - return - - # prevent concurrent regeneration - lock_key = f"agent_auth_regeneration:{self.server}" - - with frappe.cache().lock(lock_key, timeout=600): - self.reload() - - # already rotating - if self.regenerate_public_key: - return - - # preserve current public key temporarily - self.regenerate_public_key = self.public_key - - # Clear out current data - self.is_agent_auth_setup = 0 - self.expires_in = None - - self.save(ignore_permissions=True) - - # cache old key for dual verification window - frappe.cache().set_value( - f"{self.server}_regenerate_public_key", - self.regenerate_public_key, - expires_in_sec=600, # Ansible timeout - ) - - server: BaseServer = frappe.get_doc( - self.server_type, - self.server, - ) - - server._setup_agent_auth() - - -def regenerate_token(): - seven_days_from_now = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=7)).replace( - tzinfo=None - ) - - agent_auths = frappe.get_all( - "Agent Auth", - filters={ - "is_agent_auth_setup": 1, - "expires_in": ["<=", seven_days_from_now], - }, - fields=["name"], - ) - - for auth in agent_auths: - frappe.enqueue_doc( - "Agent Auth", - auth.name, - "_regenerate_token", - queue="long", - timeout=1200, - ) diff --git a/press/press/doctype/agent_auth/test_agent_auth.py b/press/press/doctype/agent_auth/test_agent_auth.py deleted file mode 100644 index 746522e21cb..00000000000 --- a/press/press/doctype/agent_auth/test_agent_auth.py +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright (c) 2026, Frappe and Contributors -# See license.txt - -from unittest.mock import patch - -import frappe -from frappe.tests import IntegrationTestCase, UnitTestCase - -from press.press.doctype.agent_auth.agent_auth import regenerate_token - -# On IntegrationTestCase, the doctype test records and all -# link-field test record dependencies are recursively loaded -# Use these module variables to add/remove to/from that list -EXTRA_TEST_RECORD_DEPENDENCIES: list[str] = [] # eg. ["User"] -IGNORE_TEST_RECORD_DEPENDENCIES: list[str] = [] # eg. ["User"] - - -class UnitTestAgentAuth(UnitTestCase): - @patch("frappe.enqueue_doc") - @patch("frappe.get_all") - def test_regenerate_token_enqueues_jobs( - self, - mock_get_all, - mock_enqueue_doc, - ): - mock_get_all.return_value = [ - frappe._dict({"name": "auth-1"}), - frappe._dict({"name": "auth-2"}), - ] - - regenerate_token() - - self.assertEqual(mock_enqueue_doc.call_count, 2) - - -class IntegrationTestAgentAuth(IntegrationTestCase): - """ - Integration tests for AgentAuth. - Use this class for testing interactions between multiple components. - """ - - pass diff --git a/press/press/doctype/agent_job/agent_job.py b/press/press/doctype/agent_job/agent_job.py index cd3a47697d1..88dd52601e6 100644 --- a/press/press/doctype/agent_job/agent_job.py +++ b/press/press/doctype/agent_job/agent_job.py @@ -8,6 +8,9 @@ import traceback from typing import TYPE_CHECKING +if TYPE_CHECKING: + from press.press.doctype.server.server import BaseServer + import frappe from frappe.core.utils import find from frappe.model.document import Document @@ -194,8 +197,6 @@ def create_http_request(self): process_job_updates(self.name) else: - frappe.cache().sadd("undelivered_jobs", f"{self.server_type}:{self.server}") - self.set_status_and_next_retry_at() def log_creation(self): @@ -527,7 +528,7 @@ def poll_pending_jobs_server(server): add_timer_data_to_monitor(server.server) -def handle_polled_job(polled_job, pending_jobs=None, job=None): +def handle_polled_job(polled_job, pending_jobs=None, job=None, raise_callback_exception=False): job = job or find(pending_jobs, lambda x: x.job_id == polled_job["id"]) try: # Update Job Status @@ -553,13 +554,22 @@ def handle_polled_job(polled_job, pending_jobs=None, job=None): # it's already logged # Rollback all other changes and increment the failure count frappe.db.rollback() + + failure_count = job.callback_failure_count + 1 + frappe.db.set_value( "Agent Job", job.name, "callback_failure_count", - job.callback_failure_count + 1, + failure_count, ) frappe.db.commit() + + if raise_callback_exception and failure_count <= 3: + raise # So that agents can retry jobs with failed callbacks + # After 3 failed retries, mark it as failure, and don't raise exception + update_job_and_step_status(job.name, "Failure") + frappe.db.commit() except Exception: log_error( "Agent Job Poll Exception", @@ -814,76 +824,100 @@ def get_next_retry_at(job_retry_count): @timer -def retry_undelivered_jobs(server): +def retry_undelivered_jobs( + server: "BaseServer", + use_exponential_backoff: bool = True, + use_queue_protection: bool = False, +) -> None: """Retry undelivered jobs and update job status if max retry count is reached""" if is_auto_retry_disabled(server): return job_types, max_retry_per_job_type = get_retryable_job_types_and_max_retry_count() + server_jobs = get_undelivered_jobs_for_server(server, job_types) nowtime = now_datetime() for server in server_jobs: - delivered_jobs = get_jobs_delivered_to_server(server, server_jobs[server]) + delivered_jobs = get_jobs_delivered_to_server( + server, + server_jobs[server], + ) if delivered_jobs: update_job_ids_for_delivered_jobs(delivered_jobs) - undelivered_jobs = list( - set(server_jobs[server]) - set([job["agent_job_id"] for job in delivered_jobs]) - ) + undelivered_jobs = list(set(server_jobs[server]) - {job["agent_job_id"] for job in delivered_jobs}) for job_name in undelivered_jobs: - job = AgentJob("Agent Job", job_name) - max_retry_count = max_retry_per_job_type[job.job_type] or 0 + process_undelivered_job( + job_name=job_name, + max_retry_per_job_type=max_retry_per_job_type, + nowtime=nowtime, + use_exponential_backoff=use_exponential_backoff, + use_queue_protection=use_queue_protection, + ) - if not job.next_retry_at and job.name not in queued_jobs(): - job.set_status_and_next_retry_at() - continue - if get_datetime(job.next_retry_at) > nowtime: - continue +def process_undelivered_job( + job_name: str, + max_retry_per_job_type: dict[str, int], + nowtime, + use_exponential_backoff: bool = True, + use_queue_protection: bool = False, +) -> None: + """Process retry logic for a single undelivered job""" - if job.retry_count <= max_retry_count: - retry = job.retry_count + 1 - frappe.db.set_value("Agent Job", job_name, "retry_count", retry, update_modified=False) - job.retry_in_place() - else: - update_job_and_step_status(job_name, "Delivery Failure") - process_job_updates(job_name) + job = AgentJob("Agent Job", job_name) + + if use_queue_protection and job.name in queued_jobs(): + # Prevent accidental retry duplication while still in queue + return + max_retry_count = max_retry_per_job_type[job.job_type] or 0 -def retry_poll(): - flag = frappe.db.get_single_value("Press Settings", "push_feature") - if not flag: + if use_exponential_backoff and not should_retry_job(job, nowtime): return - servers = frappe.cache().smembers("undelivered_jobs") + if job.retry_count <= max_retry_count: + retry_job(job_name, job) + return - for server_key in servers: - if isinstance(server_key, bytes): - server_key = server_key.decode() + mark_job_delivery_failure(job_name) - server_type, server = server_key.split(":", 1) - try: - retry_undelivered_jobs( - frappe._dict( - { - "server": server, - "server_type": server_type, - } - ) - ) - except Exception: - log_error( - "Retry undelivered jobs failed", - server=server, - server_type=server_type, - ) - else: - frappe.cache().srem("undelivered_jobs", server_key) +def should_retry_job(job: AgentJob, nowtime) -> bool: + """Check whether a job is eligible for retry""" + + if not job.next_retry_at and job.name not in queued_jobs(): + job.set_status_and_next_retry_at() + return False + + return get_datetime(job.next_retry_at) <= nowtime + + +def retry_job(job_name: str, job: AgentJob) -> None: + """Retry a failed job""" + + retry = job.retry_count + 1 + + frappe.db.set_value( + "Agent Job", + job_name, + "retry_count", + retry, + update_modified=False, + ) + + job.retry_in_place() + + +def mark_job_delivery_failure(job_name: str) -> None: + """Mark job as permanently failed""" + + update_job_and_step_status(job_name, "Delivery Failure") + process_job_updates(job_name) def queued_jobs(): diff --git a/press/press/doctype/agent_job/test_agent_job.py b/press/press/doctype/agent_job/test_agent_job.py index dced51006b1..fdadcb8ed32 100644 --- a/press/press/doctype/agent_job/test_agent_job.py +++ b/press/press/doctype/agent_job/test_agent_job.py @@ -295,55 +295,3 @@ def test_get_similar_in_execution_job(self): self.assertEqual(in_execution_job.name, job.name) frappe.db.set_single_value("Press Settings", "disable_agent_job_deduplication", True) - - @patch("press.press.doctype.agent_job.agent_job.retry_undelivered_jobs") - def test_retry_poll_removes_server_on_success( - self, - mock_retry, - ): - frappe.db.set_single_value("Press Settings", "push_feature", 1) - - frappe.cache().sadd( - "undelivered_jobs", - "Server:test-server", - ) - - from press.press.doctype.agent_job.agent_job import retry_poll - - retry_poll() - - servers = frappe.cache().smembers("undelivered_jobs") - - self.assertNotIn( - b"Server:test-server", - servers, - ) - - @patch("press.press.doctype.agent_job.agent_job.log_error") - @patch("press.press.doctype.agent_job.agent_job.retry_undelivered_jobs") - def test_retry_poll_keeps_server_on_failure( - self, - mock_retry, - mock_log_error, - ): - mock_retry.side_effect = Exception("failure") - - frappe.db.set_single_value("Press Settings", "push_feature", 1) - - frappe.cache().sadd( - "undelivered_jobs", - "Server:test-server", - ) - - from press.press.doctype.agent_job.agent_job import retry_poll - - retry_poll() - - servers = frappe.cache().smembers("undelivered_jobs") - - self.assertIn( - b"Server:test-server", - servers, - ) - - mock_log_error.assert_called_once() diff --git a/press/press/doctype/analytics_server/analytics_server.js b/press/press/doctype/analytics_server/analytics_server.js index cfcb048e17f..dc291e4711f 100644 --- a/press/press/doctype/analytics_server/analytics_server.js +++ b/press/press/doctype/analytics_server/analytics_server.js @@ -3,7 +3,7 @@ frappe.ui.form.on('Analytics Server', { refresh: function (frm) { - [ + ;[ [__('Ping Agent'), 'ping_agent', false, frm.doc.is_server_setup], [__('Ping Ansible'), 'ping_ansible', true], [__('Ping Ansible Unprepared'), 'ping_ansible_unprepared', true], @@ -11,7 +11,11 @@ frappe.ui.form.on('Analytics Server', { [__('Prepare Server'), 'prepare_server', true, !frm.doc.is_server_setup], [__('Setup Server'), 'setup_server', true, !frm.doc.is_server_setup], [ - __('Setup Agent Auth'), + __( + frm.doc.is_agent_auth_setup + ? 'Regenerate Agent Token' + : 'Setup Agent Auth', + ), 'setup_agent_auth', false, frm.doc.is_server_setup, @@ -40,25 +44,25 @@ frappe.ui.form.on('Analytics Server', { () => frm.call(method).then((r) => { if (r.message) { - frappe.msgprint(r.message); + frappe.msgprint(r.message) } else { - frm.refresh(); + frm.refresh() } }), - ); + ) } else { frm.call(method).then((r) => { if (r.message) { - frappe.msgprint(r.message); + frappe.msgprint(r.message) } else { - frm.refresh(); + frm.refresh() } - }); + }) } }, __('Actions'), - ); + ) } - }); + }) }, -}); +}) diff --git a/press/press/doctype/analytics_server/analytics_server.json b/press/press/doctype/analytics_server/analytics_server.json index 5b46dec107d..f88fcb50f7b 100644 --- a/press/press/doctype/analytics_server/analytics_server.json +++ b/press/press/doctype/analytics_server/analytics_server.json @@ -21,6 +21,8 @@ "private_vlan_id", "agent_section", "agent_password", + "is_agent_auth_setup", + "agent_job_update_feature", "ssh_section", "frappe_user_password", "frappe_public_key", @@ -67,7 +69,10 @@ "read_only": 1, "set_only_once": 1 }, - { "fieldname": "column_break_4", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, { "default": "Generic", "fieldname": "provider", @@ -105,7 +110,10 @@ "reqd": 1, "set_only_once": 1 }, - { "fieldname": "column_break_10", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_10", + "fieldtype": "Column Break" + }, { "fetch_from": "virtual_machine.private_ip_address", "fieldname": "private_ip", @@ -158,7 +166,10 @@ "label": "Frappe Public Key", "read_only": 1 }, - { "fieldname": "column_break_19", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_19", + "fieldtype": "Column Break" + }, { "fieldname": "root_public_key", "fieldtype": "Code", @@ -200,7 +211,10 @@ "label": "Plausible Mail Port", "set_only_once": 1 }, - { "fieldname": "column_break_27", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_27", + "fieldtype": "Column Break" + }, { "fieldname": "plausible_mail_password", "fieldtype": "Password", @@ -224,7 +238,10 @@ "label": "Google Client ID", "set_only_once": 1 }, - { "fieldname": "column_break_32", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_32", + "fieldtype": "Column Break" + }, { "fieldname": "google_client_secret", "fieldtype": "Password", @@ -237,11 +254,29 @@ "fieldtype": "Check", "label": "TLS Certificate Renewal Failed", "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_agent_auth_setup", + "fieldtype": "Check", + "label": "Is Agent Auth Setup", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "agent_job_update_feature", + "fieldtype": "Check", + "label": "Agent Job Update Feature" } ], "grid_page_length": 50, - "links": [{ "link_doctype": "Ansible Play", "link_fieldname": "server" }], - "modified": "2025-09-02 16:43:53.162616", + "links": [ + { + "link_doctype": "Ansible Play", + "link_fieldname": "server" + } + ], + "modified": "2026-05-24 18:49:12.626642", "modified_by": "Administrator", "module": "Press", "name": "Analytics Server", diff --git a/press/press/doctype/analytics_server/analytics_server.py b/press/press/doctype/analytics_server/analytics_server.py index 97086c38277..ccb9b68a282 100644 --- a/press/press/doctype/analytics_server/analytics_server.py +++ b/press/press/doctype/analytics_server/analytics_server.py @@ -20,6 +20,7 @@ class AnalyticsServer(BaseServer): if TYPE_CHECKING: from frappe.types import DF + agent_job_update_feature: DF.Check agent_password: DF.Password | None domain: DF.Link | None frappe_public_key: DF.Code | None @@ -28,6 +29,7 @@ class AnalyticsServer(BaseServer): google_client_secret: DF.Password | None hostname: DF.Data ip: DF.Data + is_agent_auth_setup: DF.Check is_server_setup: DF.Check monitoring_password: DF.Password | None plausible_mail_login: DF.Data | None @@ -45,6 +47,10 @@ class AnalyticsServer(BaseServer): virtual_machine: DF.Link | None # end: auto-generated types + def on_update(self): + if self.has_value_changed("agent_job_update_feature"): + self.update_feature(self.agent_job_update_feature) + def validate(self): self.validate_agent_password() self.validate_monitoring_password() diff --git a/press/press/doctype/database_server/database_server.js b/press/press/doctype/database_server/database_server.js index 11a90ff6100..d10da44ab07 100644 --- a/press/press/doctype/database_server/database_server.js +++ b/press/press/doctype/database_server/database_server.js @@ -25,7 +25,11 @@ frappe.ui.form.on('Database Server', { frm.doc.is_server_setup, ], [ - __('Setup Agent Auth'), + __( + frm.doc.is_agent_auth_setup + ? 'Regenerate Agent Token' + : 'Setup Agent Auth', + ), 'setup_agent_auth', false, frm.doc.is_server_setup, diff --git a/press/press/doctype/database_server/database_server.json b/press/press/doctype/database_server/database_server.json index b1a93590a49..56000e1fe02 100644 --- a/press/press/doctype/database_server/database_server.json +++ b/press/press/doctype/database_server/database_server.json @@ -47,6 +47,8 @@ "private_vlan_id", "agent_section", "agent_password", + "is_agent_auth_setup", + "agent_job_update_feature", "mariadb_section", "self_hosted_mariadb_server", "mariadb_root_password", @@ -78,7 +80,6 @@ "column_break_eiyu", "memory_allocator", "memory_allocator_version", - "tcmalloc_release_rate", "section_break_ladc", "is_performance_schema_enabled", "mariadb_system_variables", @@ -118,13 +119,7 @@ "binlog_index_retention_days", "column_break_blnf", "enable_binlog_upload_to_s3", - "uploaded_binlogs_retention_days", - "monitor_tab", - "is_mariadb_monitor_installed", - "section_break_uzkb", - "is_external_healthcheck_enabled", - "column_break_umtu", - "is_auto_coredump_enabled" + "uploaded_binlogs_retention_days" ], "fields": [ { @@ -156,7 +151,11 @@ "options": "Root Domain", "read_only": 1 }, - { "fieldname": "title", "fieldtype": "Data", "label": "Title" }, + { + "fieldname": "title", + "fieldtype": "Data", + "label": "Title" + }, { "default": "0", "fieldname": "tls_certificate_renewal_failed", @@ -164,7 +163,10 @@ "label": "TLS Certificate Renewal Failed", "read_only": 1 }, - { "fieldname": "column_break_4", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, { "fieldname": "cluster", "fieldtype": "Link", @@ -196,7 +198,10 @@ "fieldtype": "Data", "label": "Self Hosted Server Domain" }, - { "fieldname": "section_break_qury", "fieldtype": "Section Break" }, + { + "fieldname": "section_break_qury", + "fieldtype": "Section Break" + }, { "default": "0", "fieldname": "public", @@ -222,7 +227,10 @@ "fieldtype": "Check", "label": "Halt Agent Jobs" }, - { "fieldname": "column_break_qsqm", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_qsqm", + "fieldtype": "Column Break" + }, { "default": "0", "fieldname": "is_server_setup", @@ -268,7 +276,10 @@ "label": "Team", "options": "Team" }, - { "fieldname": "column_break_11", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_11", + "fieldtype": "Column Break" + }, { "fieldname": "plan", "fieldtype": "Link", @@ -310,7 +321,10 @@ "in_list_view": 1, "label": "IP" }, - { "fieldname": "column_break_10", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_10", + "fieldtype": "Column Break" + }, { "fetch_from": "virtual_machine.private_ip_address", "fieldname": "private_ip", @@ -368,7 +382,10 @@ "label": "MariaDB Root Password", "read_only": 1 }, - { "fieldname": "column_break_12", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_12", + "fieldtype": "Column Break" + }, { "fieldname": "server_id", "fieldtype": "Int", @@ -392,8 +409,15 @@ "label": "Frappe Public Key", "read_only": 1 }, - { "fieldname": "column_break_18", "fieldtype": "Column Break" }, - { "fieldname": "ssh_user", "fieldtype": "Data", "label": "SSH User" }, + { + "fieldname": "column_break_18", + "fieldtype": "Column Break" + }, + { + "fieldname": "ssh_user", + "fieldtype": "Data", + "label": "SSH User" + }, { "fieldname": "bastion_server", "fieldtype": "Link", @@ -412,9 +436,19 @@ "label": "Root Public Key", "read_only": 1 }, - { "fieldname": "section_break_cees", "fieldtype": "Section Break" }, - { "fieldname": "ram", "fieldtype": "Float", "label": "RAM (MB)" }, - { "fieldname": "column_break_apox", "fieldtype": "Column Break" }, + { + "fieldname": "section_break_cees", + "fieldtype": "Section Break" + }, + { + "fieldname": "ram", + "fieldtype": "Float", + "label": "RAM (MB)" + }, + { + "fieldname": "column_break_apox", + "fieldtype": "Column Break" + }, { "fieldname": "tags_section", "fieldtype": "Section Break", @@ -485,7 +519,10 @@ "label": "Memory Swap Max (GB)", "non_negative": 1 }, - { "fieldname": "column_break_eiyu", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_eiyu", + "fieldtype": "Column Break" + }, { "default": "TCMalloc", "fieldname": "memory_allocator", @@ -501,14 +538,9 @@ "read_only": 1 }, { - "default": "1", - "depends_on": "eval: doc.memory_allocator == \"TCMalloc\"", - "fieldname": "tcmalloc_release_rate", - "fieldtype": "Int", - "label": "TCMalloc Release Rate", - "read_only": 1 + "fieldname": "section_break_ladc", + "fieldtype": "Section Break" }, - { "fieldname": "section_break_ladc", "fieldtype": "Section Break" }, { "default": "0", "fieldname": "is_performance_schema_enabled", @@ -547,7 +579,10 @@ "fieldtype": "Check", "label": "Stalk strace Collector" }, - { "fieldname": "column_break_qrkk", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_qrkk", + "fieldtype": "Column Break" + }, { "default": "status", "fieldname": "stalk_function", @@ -566,7 +601,10 @@ "fieldtype": "Int", "label": "Stalk Threshold" }, - { "fieldname": "column_break_objb", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_objb", + "fieldtype": "Column Break" + }, { "default": "1", "fieldname": "stalk_interval", @@ -585,7 +623,10 @@ "fieldtype": "Int", "label": "Stalk Sleep" }, - { "fieldname": "section_break_imng", "fieldtype": "Section Break" }, + { + "fieldname": "section_break_imng", + "fieldtype": "Section Break" + }, { "default": "0", "fieldname": "auto_purge_binlog_based_on_size", @@ -599,7 +640,10 @@ "label": "Binlog Max Disk Usage Percent (%)", "non_negative": 1 }, - { "fieldname": "column_break_hrsi", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_hrsi", + "fieldtype": "Column Break" + }, { "fieldname": "replication_tab", "fieldtype": "Tab Break", @@ -613,7 +657,10 @@ "in_standard_filter": 1, "label": "Is Primary" }, - { "fieldname": "column_break_vajj", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_vajj", + "fieldtype": "Column Break" + }, { "default": "0", "depends_on": "eval: !doc.is_primary", @@ -633,21 +680,30 @@ "mandatory_depends_on": "eval: !doc.is_primary", "options": "Database Server" }, - { "fieldname": "section_break_qrcb", "fieldtype": "Section Break" }, + { + "fieldname": "section_break_qrcb", + "fieldtype": "Section Break" + }, { "fieldname": "gtid_binlog_pos", "fieldtype": "Data", "label": "GTID Binlog Pos", "read_only": 1 }, - { "fieldname": "column_break_clus", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_clus", + "fieldtype": "Column Break" + }, { "fieldname": "gtid_current_pos", "fieldtype": "Data", "label": "GTID Current Pos", "read_only": 1 }, - { "fieldname": "column_break_gasc", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_gasc", + "fieldtype": "Column Break" + }, { "fieldname": "gtid_slave_pos", "fieldtype": "Data", @@ -678,7 +734,10 @@ "hidden": 1, "label": "Binlogs Removed" }, - { "fieldname": "column_break_coih", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_coih", + "fieldtype": "Column Break" + }, { "default": "14", "fieldname": "binlog_retention_days", @@ -691,7 +750,10 @@ "fieldtype": "Int", "label": "Binlog Index Retention (days)" }, - { "fieldname": "column_break_blnf", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_blnf", + "fieldtype": "Column Break" + }, { "default": "0", "fieldname": "enable_binlog_upload_to_s3", @@ -728,31 +790,18 @@ "label": "NAT Server", "options": "NAT Server" }, - { - "fieldname": "monitor_tab", - "fieldtype": "Tab Break", - "label": "Monitor" - }, { "default": "0", - "fieldname": "is_mariadb_monitor_installed", + "fieldname": "is_agent_auth_setup", "fieldtype": "Check", - "label": "Is MariaDB Monitor Installed", + "label": "Is Agent Auth Setup", "read_only": 1 }, - { "fieldname": "section_break_uzkb", "fieldtype": "Section Break" }, { "default": "0", - "fieldname": "is_external_healthcheck_enabled", + "fieldname": "agent_job_update_feature", "fieldtype": "Check", - "label": "Is External Healthcheck Enabled" - }, - { "fieldname": "column_break_umtu", "fieldtype": "Column Break" }, - { - "default": "0", - "fieldname": "is_auto_coredump_enabled", - "fieldtype": "Check", - "label": "Is Auto Coredump Enabled" + "label": "Agent Job Update Feature" } ], "grid_page_length": 50, @@ -769,7 +818,7 @@ "link_fieldname": "database_server" } ], - "modified": "2026-05-06 14:39:54.175744", + "modified": "2026-05-24 18:48:47.951310", "modified_by": "Administrator", "module": "Press", "name": "Database Server", @@ -787,7 +836,18 @@ "share": 1, "write": 1 }, - { "create": 1, "read": 1, "role": "Press User", "write": 1 } + { + "create": 1, + "read": 1, + "role": "Press Admin", + "write": 1 + }, + { + "create": 1, + "read": 1, + "role": "Press Member", + "write": 1 + } ], "row_format": "Dynamic", "sort_field": "modified", diff --git a/press/press/doctype/database_server/database_server.py b/press/press/doctype/database_server/database_server.py index 47627653142..71e865c8bd4 100644 --- a/press/press/doctype/database_server/database_server.py +++ b/press/press/doctype/database_server/database_server.py @@ -49,6 +49,7 @@ class DatabaseServer(BaseServer): from press.press.doctype.resource_tag.resource_tag import ResourceTag from press.press.doctype.server_mount.server_mount import ServerMount + agent_job_update_feature: DF.Check agent_password: DF.Password | None auto_add_storage_max: DF.Int auto_add_storage_min: DF.Int @@ -77,11 +78,9 @@ class DatabaseServer(BaseServer): hostname: DF.Data hostname_abbreviation: DF.Data | None ip: DF.Data | None - is_auto_coredump_enabled: DF.Check + is_agent_auth_setup: DF.Check is_binlog_indexer_running: DF.Check - is_external_healthcheck_enabled: DF.Check is_for_recovery: DF.Check - is_mariadb_monitor_installed: DF.Check is_monitoring_disabled: DF.Check is_performance_schema_enabled: DF.Check is_primary: DF.Check @@ -128,7 +127,6 @@ class DatabaseServer(BaseServer): stalk_variable: DF.Data | None status: DF.Literal["Pending", "Installing", "Active", "Broken", "Archived"] tags: DF.Table[ResourceTag] - tcmalloc_release_rate: DF.Int team: DF.Link | None title: DF.Data | None tls_certificate_renewal_failed: DF.Check @@ -250,6 +248,9 @@ def on_update(self): if self.public: self.auto_add_storage_min = max(self.auto_add_storage_min, PUBLIC_SERVER_AUTO_ADD_STORAGE_MIN) + if self.has_value_changed("agent_job_update_feature"): + self.update_feature(self.agent_job_update_feature) + def publish_linked_server_realtime_update(self): with contextlib.suppress(Exception): app_server = frappe.db.exists( @@ -816,9 +817,8 @@ def validate_server_id(self): def _setup_server(self): config = self._get_config() - private_key = self._generate_and_activate_key() - agent_token = self.sign_agent_token(private_key) - auth = self.agent_auth + secret = self._generate_secret() + agent_token = self.sign_agent_token(secret) try: ansible = Ansible( @@ -856,8 +856,7 @@ def _setup_server(self): if play.status == "Success": self.status = "Active" self.is_server_setup = True - auth.is_agent_auth_setup = 1 - auth.save(ignore_permissions=True) + self.is_agent_auth_setup = 1 self.process_hybrid_server_setup() if self.provider == "DigitalOcean": # Adjusting docker permissions diff --git a/press/press/doctype/database_server/test_database_server.py b/press/press/doctype/database_server/test_database_server.py index 7850e6321ee..08c48ff68b2 100644 --- a/press/press/doctype/database_server/test_database_server.py +++ b/press/press/doctype/database_server/test_database_server.py @@ -150,55 +150,3 @@ def test_adjust_memory_config_sets_memory_limits_with_some_buffer(self): ).value_int, int(15007.248 * 0.65), ) - - @patch("press.press.doctype.database_server.database_server.Ansible") - def test_setup_server_marks_agent_auth_as_setup(self, Mock_Ansible): - server = create_test_database_server() - - server._get_config = Mock( - return_value=frappe._dict( - { - "agent_password": "test", # pragma: allowlist secret - "agent_repository_url": "test", - "agent_branch": "main", - "monitoring_password": "test", # pragma: allowlist secret - "log_server": None, - "kibana_password": None, - "mariadb_root_password": "test", # pragma: allowlist secret - "certificate": frappe._dict( - { - "private_key": "key", # pragma: allowlist secret - "full_chain": "chain", - "intermediate_chain": "intermediate", - } - ), - } - ) - ) - - server._generate_and_activate_key = Mock(return_value="private-key") - server.sign_agent_token = Mock(return_value="agent-token") - server.process_hybrid_server_setup = Mock() - - auth = frappe._dict( - { - "is_agent_auth_setup": 0, - "save": Mock(), - } - ) - - server.agent_auth = auth - - play = frappe._dict({"status": "Success"}) - Mock_Ansible.return_value.run.return_value = play - - server.save = Mock() - - server._setup_server() - - server._generate_and_activate_key.assert_called_once() - server.sign_agent_token.assert_called_once_with("private-key") - - self.assertEqual(auth.is_agent_auth_setup, 1) - - auth.save.assert_called_once_with(ignore_permissions=True) diff --git a/press/press/doctype/log_server/log_server.js b/press/press/doctype/log_server/log_server.js index 0bdbd313909..e7a18215f66 100644 --- a/press/press/doctype/log_server/log_server.js +++ b/press/press/doctype/log_server/log_server.js @@ -3,7 +3,7 @@ frappe.ui.form.on('Log Server', { refresh: function (frm) { - [ + ;[ [__('Ping Agent'), 'ping_agent', false, frm.doc.is_server_setup], [__('Ping Ansible'), 'ping_ansible', true], [__('Ping Ansible Unprepared'), 'ping_ansible_unprepared', true], @@ -25,7 +25,11 @@ frappe.ui.form.on('Log Server', { frm.doc.is_server_setup, ], [ - __('Setup Agent Auth'), + __( + frm.doc.is_agent_auth_setup + ? 'Regenerate Agent Token' + : 'Setup Agent Auth', + ), 'setup_agent_auth', false, frm.doc.is_server_setup, @@ -42,25 +46,25 @@ frappe.ui.form.on('Log Server', { () => frm.call(method).then((r) => { if (r.message) { - frappe.msgprint(r.message); + frappe.msgprint(r.message) } else { - frm.refresh(); + frm.refresh() } }), - ); + ) } else { frm.call(method).then((r) => { if (r.message) { - frappe.msgprint(r.message); + frappe.msgprint(r.message) } else { - frm.refresh(); + frm.refresh() } - }); + }) } }, __('Actions'), - ); + ) } - }); + }) }, -}); +}) diff --git a/press/press/doctype/log_server/log_server.json b/press/press/doctype/log_server/log_server.json index dec9d0f1d62..8bca7e4c400 100644 --- a/press/press/doctype/log_server/log_server.json +++ b/press/press/doctype/log_server/log_server.json @@ -23,6 +23,8 @@ "private_vlan_id", "agent_section", "agent_password", + "is_agent_auth_setup", + "agent_job_update_feature", "ssh_section", "ssh_user", "ssh_port", @@ -62,7 +64,10 @@ "read_only": 1, "set_only_once": 1 }, - { "fieldname": "column_break_4", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, { "default": "Generic", "fieldname": "provider", @@ -91,7 +96,10 @@ "label": "IP", "set_only_once": 1 }, - { "fieldname": "column_break_9", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, { "fetch_from": "virtual_machine.private_ip_address", "fieldname": "private_ip", @@ -144,7 +152,10 @@ "label": "Frappe Public Key", "read_only": 1 }, - { "fieldname": "column_break_20", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_20", + "fieldtype": "Column Break" + }, { "fieldname": "root_public_key", "fieldtype": "Code", @@ -186,7 +197,11 @@ "label": "Cluster", "options": "Cluster" }, - { "fieldname": "ssh_user", "fieldtype": "Data", "label": "SSH User" }, + { + "fieldname": "ssh_user", + "fieldtype": "Data", + "label": "SSH User" + }, { "default": "22", "fieldname": "ssh_port", @@ -205,10 +220,28 @@ "fieldtype": "Link", "label": "Plan", "options": "Server Plan" + }, + { + "default": "0", + "fieldname": "is_agent_auth_setup", + "fieldtype": "Check", + "label": "Is Agent Auth Setup", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "agent_job_update_feature", + "fieldtype": "Check", + "label": "Agent Job Update Feature" + } + ], + "links": [ + { + "link_doctype": "Ansible Play", + "link_fieldname": "server" } ], - "links": [{ "link_doctype": "Ansible Play", "link_fieldname": "server" }], - "modified": "2025-11-22 15:50:53.703274", + "modified": "2026-05-24 18:48:16.335128", "modified_by": "Administrator", "module": "Press", "name": "Log Server", diff --git a/press/press/doctype/log_server/log_server.py b/press/press/doctype/log_server/log_server.py index a993a5782e0..5f3947091e3 100644 --- a/press/press/doctype/log_server/log_server.py +++ b/press/press/doctype/log_server/log_server.py @@ -18,6 +18,7 @@ class LogServer(BaseServer): if TYPE_CHECKING: from frappe.types import DF + agent_job_update_feature: DF.Check agent_password: DF.Password | None cluster: DF.Link | None domain: DF.Link | None @@ -25,6 +26,7 @@ class LogServer(BaseServer): frappe_user_password: DF.Password | None hostname: DF.Data ip: DF.Data | None + is_agent_auth_setup: DF.Check is_server_setup: DF.Check kibana_password: DF.Password | None monitoring_password: DF.Password | None @@ -41,6 +43,10 @@ class LogServer(BaseServer): virtual_machine: DF.Link | None # end: auto-generated types + def on_update(self): + if self.has_value_changed("agent_job_update_feature"): + self.update_feature(self.agent_job_update_feature) + def validate(self): self.validate_agent_password() self.validate_monitoring_password() diff --git a/press/press/doctype/monitor_server/monitor_server.js b/press/press/doctype/monitor_server/monitor_server.js index 0e7f1613ad2..97dec8f5b97 100644 --- a/press/press/doctype/monitor_server/monitor_server.js +++ b/press/press/doctype/monitor_server/monitor_server.js @@ -3,7 +3,7 @@ frappe.ui.form.on('Monitor Server', { refresh: function (frm) { - [ + ;[ [__('Ping Agent'), 'ping_agent', false, frm.doc.is_server_setup], [__('Ping Ansible'), 'ping_ansible', true], [__('Ping Ansible Unprepared'), 'ping_ansible_unprepared', true], @@ -31,7 +31,11 @@ frappe.ui.form.on('Monitor Server', { frm.doc.is_server_setup, ], [ - __('Setup Agent Auth'), + __( + frm.doc.is_agent_auth_setup + ? 'Regenerate Agent Token' + : 'Setup Agent Auth', + ), 'setup_agent_auth', false, frm.doc.is_server_setup, @@ -48,25 +52,25 @@ frappe.ui.form.on('Monitor Server', { () => frm.call(method).then((r) => { if (r.message) { - frappe.msgprint(r.message); + frappe.msgprint(r.message) } else { - frm.refresh(); + frm.refresh() } }), - ); + ) } else { frm.call(method).then((r) => { if (r.message) { - frappe.msgprint(r.message); + frappe.msgprint(r.message) } else { - frm.refresh(); + frm.refresh() } - }); + }) } }, __('Actions'), - ); + ) } - }); + }) }, -}); +}) diff --git a/press/press/doctype/monitor_server/monitor_server.json b/press/press/doctype/monitor_server/monitor_server.json index 18027a93921..1ccfc9cfb1f 100644 --- a/press/press/doctype/monitor_server/monitor_server.json +++ b/press/press/doctype/monitor_server/monitor_server.json @@ -23,6 +23,8 @@ "private_vlan_id", "agent_section", "agent_password", + "is_agent_auth_setup", + "agent_job_update_feature", "grafana_section", "grafana_username", "prometheus_username", @@ -70,7 +72,10 @@ "read_only": 1, "set_only_once": 1 }, - { "fieldname": "column_break_4", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, { "default": "Generic", "fieldname": "provider", @@ -99,7 +104,10 @@ "label": "IP", "set_only_once": 1 }, - { "fieldname": "column_break_9", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, { "fetch_from": "virtual_machine.private_ip_address", "fieldname": "private_ip", @@ -162,7 +170,10 @@ "label": "Frappe Public Key", "read_only": 1 }, - { "fieldname": "column_break_20", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_20", + "fieldtype": "Column Break" + }, { "fieldname": "root_public_key", "fieldtype": "Code", @@ -195,7 +206,10 @@ "label": "Cluster", "options": "Cluster" }, - { "fieldname": "column_break_nzet", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_nzet", + "fieldtype": "Column Break" + }, { "default": "/home/frappe/prometheus/data", "fieldname": "prometheus_data_directory", @@ -207,7 +221,10 @@ "fieldtype": "Data", "label": "Grafana Username" }, - { "fieldname": "column_break_ilpd", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_ilpd", + "fieldtype": "Column Break" + }, { "description": "Begin with / but don't end with /", "fieldname": "node_exporter_dashboard_path", @@ -220,7 +237,11 @@ "fieldtype": "Data", "label": "Prometheus Username" }, - { "fieldname": "ssh_user", "fieldtype": "Data", "label": "SSH User" }, + { + "fieldname": "ssh_user", + "fieldtype": "Data", + "label": "SSH User" + }, { "default": "22", "fieldname": "ssh_port", @@ -250,11 +271,29 @@ "fieldtype": "Link", "label": "Plan", "options": "Server Plan" + }, + { + "default": "0", + "fieldname": "is_agent_auth_setup", + "fieldtype": "Check", + "label": "Is Agent Auth Setup", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "agent_job_update_feature", + "fieldtype": "Check", + "label": "Agent Job Update Feature" } ], "grid_page_length": 50, - "links": [{ "link_doctype": "Ansible Play", "link_fieldname": "server" }], - "modified": "2026-02-16 11:36:45.578971", + "links": [ + { + "link_doctype": "Ansible Play", + "link_fieldname": "server" + } + ], + "modified": "2026-05-24 18:47:47.717742", "modified_by": "Administrator", "module": "Press", "name": "Monitor Server", diff --git a/press/press/doctype/monitor_server/monitor_server.py b/press/press/doctype/monitor_server/monitor_server.py index 6a87498cdcf..0340c5f8694 100644 --- a/press/press/doctype/monitor_server/monitor_server.py +++ b/press/press/doctype/monitor_server/monitor_server.py @@ -41,6 +41,7 @@ class MonitorServer(BaseServer): if TYPE_CHECKING: from frappe.types import DF + agent_job_update_feature: DF.Check agent_password: DF.Password | None cluster: DF.Link | None domain: DF.Link | None @@ -50,6 +51,7 @@ class MonitorServer(BaseServer): grafana_username: DF.Data | None hostname: DF.Data ip: DF.Data | None + is_agent_auth_setup: DF.Check is_server_setup: DF.Check monitoring_password: DF.Password | None node_exporter_dashboard_path: DF.Data | None @@ -70,6 +72,10 @@ class MonitorServer(BaseServer): webhook_token: DF.Data | None # end: auto-generated types + def on_update(self): + if self.has_value_changed("agent_job_update_feature"): + self.update_feature(self.agent_job_update_feature) + def validate(self): self.validate_agent_password() self.validate_grafana_password() diff --git a/press/press/doctype/nat_server/nat_server.js b/press/press/doctype/nat_server/nat_server.js index ab3263d7de8..aaee2b1d1ff 100644 --- a/press/press/doctype/nat_server/nat_server.js +++ b/press/press/doctype/nat_server/nat_server.js @@ -3,7 +3,7 @@ frappe.ui.form.on('NAT Server', { refresh(frm) { - [ + ;[ [__('Prepare Server'), 'prepare_server', true, !frm.doc.is_server_setup], [__('Setup Server'), 'setup_server', true, !frm.doc.is_server_setup], [__('Ping Ansible'), 'ping_ansible', true, frm.doc.is_server_setup], @@ -17,7 +17,11 @@ frappe.ui.form.on('NAT Server', { frm.doc.is_server_setup, ], // added temporarily for setting up nginx & monitoring config [ - __('Setup Agent Auth'), + __( + frm.doc.is_agent_auth_setup + ? 'Regenerate Agent Token' + : 'Setup Agent Auth', + ), 'setup_agent_auth', false, frm.doc.is_server_setup, @@ -33,26 +37,26 @@ frappe.ui.form.on('NAT Server', { () => frm.call(method).then((r) => { if (r.message) { - frappe.msgprint(r.message); + frappe.msgprint(r.message) } else { - frm.refresh(); + frm.refresh() } }), - ); + ) } else { frm.call(method).then((r) => { if (r.message) { - frappe.msgprint(r.message); + frappe.msgprint(r.message) } else { - frm.refresh(); + frm.refresh() } - }); + }) } }, __('Actions'), - ); + ) } - }); + }) if (frm.doc.status === 'Active' && !!frm.doc.secondary_private_ip) { frm.add_custom_button( @@ -75,7 +79,7 @@ frappe.ui.form.on('NAT Server', { cluster: frm.doc.cluster, secondary_private_ip: ['is', 'not set'], }, - }; + } }, }, ], @@ -91,15 +95,15 @@ frappe.ui.form.on('NAT Server', { }) .then((r) => { if (r.message) { - frappe.msgprint(r.message); + frappe.msgprint(r.message) } - }); - d.hide(); + }) + d.hide() }, - }).show(); + }).show() }, __('Actions'), - ); + ) } }, -}); +}) diff --git a/press/press/doctype/nat_server/nat_server.json b/press/press/doctype/nat_server/nat_server.json index abe6ee382eb..0cc72bf4c2b 100644 --- a/press/press/doctype/nat_server/nat_server.json +++ b/press/press/doctype/nat_server/nat_server.json @@ -22,6 +22,8 @@ "secondary_private_ip", "agent_section", "agent_password", + "is_agent_auth_setup", + "agent_job_update_feature", "ssh_section", "ssh_port", "frappe_public_key", @@ -38,14 +40,21 @@ "options": "Pending\nInstalling\nActive\nBroken\nArchived", "read_only": 1 }, - { "fieldname": "hostname", "fieldtype": "Data", "label": "Hostname" }, + { + "fieldname": "hostname", + "fieldtype": "Data", + "label": "Hostname" + }, { "fieldname": "domain", "fieldtype": "Link", "label": "Domain", "options": "Root Domain" }, - { "fieldname": "column_break_vpdt", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_vpdt", + "fieldtype": "Column Break" + }, { "fieldname": "cluster", "fieldtype": "Link", @@ -86,7 +95,10 @@ "fieldtype": "Check", "label": "Is Static Ip" }, - { "fieldname": "column_break_oygq", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_oygq", + "fieldtype": "Column Break" + }, { "fetch_from": "virtual_machine.private_ip_address", "fieldname": "private_ip", @@ -110,7 +122,10 @@ "label": "Frappe Public Key", "read_only": 1 }, - { "fieldname": "column_break_cpxp", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_cpxp", + "fieldtype": "Column Break" + }, { "fieldname": "root_public_key", "fieldtype": "Code", @@ -148,6 +163,19 @@ "fieldtype": "Password", "label": "Agent Password", "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_agent_auth_setup", + "fieldtype": "Check", + "label": "Is Agent Auth Setup", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "agent_job_update_feature", + "fieldtype": "Check", + "label": "Agent Job Update Feature" } ], "grid_page_length": 50, @@ -159,7 +187,7 @@ "link_fieldname": "server" } ], - "modified": "2026-04-27 11:32:03.470171", + "modified": "2026-05-24 18:47:22.133716", "modified_by": "Administrator", "module": "Press", "name": "NAT Server", @@ -179,7 +207,6 @@ } ], "row_format": "Dynamic", - "rows_threshold_for_grid_search": 20, "sort_field": "creation", "sort_order": "DESC", "states": [] diff --git a/press/press/doctype/nat_server/nat_server.py b/press/press/doctype/nat_server/nat_server.py index 74cfbfc4f3b..e98a8483da8 100644 --- a/press/press/doctype/nat_server/nat_server.py +++ b/press/press/doctype/nat_server/nat_server.py @@ -17,12 +17,14 @@ class NATServer(BaseServer): if TYPE_CHECKING: from frappe.types import DF + agent_job_update_feature: DF.Check agent_password: DF.Password | None cluster: DF.Link | None domain: DF.Link | None frappe_public_key: DF.Code | None hostname: DF.Data | None ip: DF.Data | None + is_agent_auth_setup: DF.Check is_server_setup: DF.Check is_static_ip: DF.Check private_ip: DF.Data | None @@ -35,6 +37,10 @@ class NATServer(BaseServer): virtual_machine: DF.Link | None # end: auto-generated types + def on_update(self): + if self.has_value_changed("agent_job_update_feature"): + self.update_feature(self.agent_job_update_feature) + def validate(self): self.validate_cluster() self.validate_agent_password() diff --git a/press/press/doctype/press_settings/press_settings.json b/press/press/doctype/press_settings/press_settings.json index ab0e6fcd413..2b0d8be5c6a 100644 --- a/press/press/doctype/press_settings/press_settings.json +++ b/press/press/doctype/press_settings/press_settings.json @@ -20,10 +20,6 @@ "column_break_wrqp", "usage_record_creation_batch_size", "default_server_plan_type", - "plans_section", - "default_dedicated_server_site_warranty_change_cooldown", - "column_break_kujg", - "default_dedicated_server_site_warranty_quota", "invoicing_section", "invoicing_column", "gst_percentage", @@ -211,7 +207,7 @@ "column_break_105", "agent_github_access_token", "branch", - "push_feature", + "secret", "lets_encrypt_section", "certbot_directory", "webroot_directory", @@ -295,7 +291,10 @@ "fieldtype": "Int", "label": "Number of Sites in Trial" }, - { "fieldname": "column_break_2", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, { "default": "{}", "fieldname": "bench_configuration", @@ -361,7 +360,10 @@ "label": "Stripe Webhook Secret", "read_only": 1 }, - { "fieldname": "column_break_26", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_26", + "fieldtype": "Column Break" + }, { "fieldname": "stripe_secret_key", "fieldtype": "Password", @@ -401,7 +403,10 @@ "fieldtype": "Data", "label": "Razorpay Webhook Secret" }, - { "fieldname": "column_break_123", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_123", + "fieldtype": "Column Break" + }, { "fieldname": "razorpay_key_secret", "fieldtype": "Password", @@ -413,7 +418,11 @@ "fieldtype": "Section Break", "label": "ERPNext Authentication" }, - { "fieldname": "erpnext_url", "fieldtype": "Data", "label": "ERPNext URL" }, + { + "fieldname": "erpnext_url", + "fieldtype": "Data", + "label": "ERPNext URL" + }, { "fieldname": "erpnext_api_key", "fieldtype": "Data", @@ -424,20 +433,30 @@ "fieldtype": "Password", "label": "ERPNext API Secret" }, - { "fieldname": "column_break_38", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_38", + "fieldtype": "Column Break" + }, { "collapsible": 1, "fieldname": "frappeio_authentication_section", "fieldtype": "Section Break", "label": "Frappe.io Authentication" }, - { "fieldname": "frappe_url", "fieldtype": "Data", "label": "URL" }, + { + "fieldname": "frappe_url", + "fieldtype": "Data", + "label": "URL" + }, { "fieldname": "frappeio_api_key", "fieldtype": "Data", "label": "Frappe.io API Key" }, - { "fieldname": "column_break_39", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_39", + "fieldtype": "Column Break" + }, { "fieldname": "frappeio_api_secret", "fieldtype": "Password", @@ -470,14 +489,20 @@ "fieldtype": "Data", "label": "Bucket Name" }, - { "fieldname": "data_40", "fieldtype": "Data" }, + { + "fieldname": "data_40", + "fieldtype": "Data" + }, { "fieldname": "backup_rotation_scheme", "fieldtype": "Select", "label": "Backup Rotation Scheme", "options": "FIFO\nGrandfather-father-son" }, - { "fieldname": "column_break_35", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_35", + "fieldtype": "Column Break" + }, { "fieldname": "offsite_backups_access_key_id", "fieldtype": "Data", @@ -511,14 +536,21 @@ "fieldtype": "Int", "label": "Backup Offset" }, - { "fieldname": "column_break_48", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_48", + "fieldtype": "Column Break" + }, { "description": "Number of backups to take per ScheduledBackupJob", "fieldname": "backup_limit", "fieldtype": "Int", "label": "Backup Limit" }, - { "fieldname": "docker_tab", "fieldtype": "Tab Break", "label": "Docker" }, + { + "fieldname": "docker_tab", + "fieldtype": "Tab Break", + "label": "Docker" + }, { "fieldname": "section_break_59", "fieldtype": "Section Break", @@ -534,7 +566,10 @@ "fieldtype": "Data", "label": "Docker Registry Namespace" }, - { "fieldname": "column_break_64", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_64", + "fieldtype": "Column Break" + }, { "fieldname": "docker_registry_username", "fieldtype": "Data", @@ -561,8 +596,15 @@ "fieldtype": "Data", "label": "Build Directory" }, - { "fieldname": "column_break_66", "fieldtype": "Column Break" }, - { "fieldname": "code_server", "fieldtype": "Data", "label": "Code Server" }, + { + "fieldname": "column_break_66", + "fieldtype": "Column Break" + }, + { + "fieldname": "code_server", + "fieldtype": "Data", + "label": "Code Server" + }, { "fieldname": "code_server_password", "fieldtype": "Data", @@ -596,7 +638,10 @@ "fieldtype": "Int", "label": "Link Expiry" }, - { "fieldname": "column_break_51", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_51", + "fieldtype": "Column Break" + }, { "fieldname": "remote_access_key_id", "fieldtype": "Data", @@ -668,7 +713,10 @@ "label": "ERPNext Group", "options": "Release Group" }, - { "fieldname": "column_break_89", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_89", + "fieldtype": "Column Break" + }, { "fieldname": "erpnext_apps", "fieldtype": "Table", @@ -717,7 +765,10 @@ "fieldtype": "Int", "label": "Standby Pool Size" }, - { "fieldname": "column_break_95", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_95", + "fieldtype": "Column Break" + }, { "default": "1", "fieldname": "standby_queue_size", @@ -739,7 +790,10 @@ "fieldtype": "Data", "label": "Telegram Chat ID" }, - { "fieldname": "column_break_65", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_65", + "fieldtype": "Column Break" + }, { "fieldname": "telegram_bot_token", "fieldtype": "Data", @@ -750,9 +804,20 @@ "fieldtype": "Section Break", "label": "Mailgun" }, - { "fieldname": "mailgun_api_key", "fieldtype": "Data", "label": "Api Key" }, - { "fieldname": "root_domain", "fieldtype": "Data", "label": "Root Domain" }, - { "fieldname": "column_break_117", "fieldtype": "Column Break" }, + { + "fieldname": "mailgun_api_key", + "fieldtype": "Data", + "label": "Api Key" + }, + { + "fieldname": "root_domain", + "fieldtype": "Data", + "label": "Root Domain" + }, + { + "fieldname": "column_break_117", + "fieldtype": "Column Break" + }, { "fieldname": "default_outgoing_id", "fieldtype": "Data", @@ -862,7 +927,10 @@ "fieldtype": "Data", "label": "Agent Repository Owner" }, - { "fieldname": "column_break_105", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_105", + "fieldtype": "Column Break" + }, { "fieldname": "agent_github_access_token", "fieldtype": "Data", @@ -892,7 +960,10 @@ "options": "2048\n3072\n4096", "reqd": 1 }, - { "fieldname": "column_break_15", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_15", + "fieldtype": "Column Break" + }, { "fieldname": "eff_registration_email", "fieldtype": "Data", @@ -967,7 +1038,11 @@ "fieldtype": "Float", "label": "Marketplace Commission" }, - { "fieldname": "usd_rate", "fieldtype": "Float", "label": "USD Rate" }, + { + "fieldname": "usd_rate", + "fieldtype": "Float", + "label": "USD Rate" + }, { "fieldname": "press_monitoring_password", "fieldtype": "Password", @@ -1028,7 +1103,10 @@ "fieldtype": "Data", "label": "AWS Access Key ID" }, - { "fieldname": "column_break_agig", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_agig", + "fieldtype": "Column Break" + }, { "fieldname": "aws_secret_access_key", "fieldtype": "Password", @@ -1044,20 +1122,32 @@ "fieldtype": "Data", "label": "Twilio Account SID" }, - { "fieldname": "column_break_kxuj", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_kxuj", + "fieldtype": "Column Break" + }, { "fieldname": "twilio_phone_number", "fieldtype": "Phone", "label": "Twilio Phone Number" }, - { "fieldname": "invoicing_column", "fieldtype": "Column Break" }, + { + "fieldname": "invoicing_column", + "fieldtype": "Column Break" + }, { "fieldname": "gst_percentage", "fieldtype": "Float", "label": "GST Percentage" }, - { "fieldname": "column_break_tcmy", "fieldtype": "Column Break" }, - { "fieldname": "column_break_edst", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_tcmy", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_edst", + "fieldtype": "Column Break" + }, { "fieldname": "twilio_api_key_sid", "fieldtype": "Data", @@ -1073,7 +1163,10 @@ "fieldtype": "Section Break", "label": "Invoicing" }, - { "fieldname": "column_break_qfwx", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_qfwx", + "fieldtype": "Column Break" + }, { "description": "Fetched from frappe.io", "fieldname": "print_format", @@ -1095,7 +1188,10 @@ "fieldtype": "Check", "label": "Compress App Cache" }, - { "fieldname": "column_break_rdlr", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_rdlr", + "fieldtype": "Column Break" + }, { "default": "0", "fieldname": "realtime_job_updates", @@ -1175,9 +1271,18 @@ "fieldtype": "Data", "label": "Branch" }, - { "fieldname": "column_break_yhwz", "fieldtype": "Column Break" }, - { "fieldname": "column_break_cpry", "fieldtype": "Column Break" }, - { "fieldname": "column_break_wrqp", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_yhwz", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_cpry", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_wrqp", + "fieldtype": "Column Break" + }, { "default": "500", "fieldname": "usage_record_creation_batch_size", @@ -1190,7 +1295,10 @@ "label": "Press Trial Plan", "options": "Site Plan" }, - { "fieldname": "section_break_jstu", "fieldtype": "Section Break" }, + { + "fieldname": "section_break_jstu", + "fieldtype": "Section Break" + }, { "default": "0", "fieldname": "enable_app_grouping", @@ -1235,7 +1343,10 @@ "fieldtype": "Int", "label": "Partnership Fee USD" }, - { "fieldname": "column_break_yxrj", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_yxrj", + "fieldtype": "Column Break" + }, { "fieldname": "partnership_fee_inr", "fieldtype": "Int", @@ -1273,7 +1384,10 @@ "fieldtype": "Data", "label": "Spamd Endpoint" }, - { "fieldname": "column_break_xhfy", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_xhfy", + "fieldtype": "Column Break" + }, { "fieldname": "spamd_api_key", "fieldtype": "Data", @@ -1296,7 +1410,10 @@ "fieldtype": "Check", "label": "Send Telegram Notifications" }, - { "fieldname": "column_break_jlzi", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_jlzi", + "fieldtype": "Column Break" + }, { "default": "0", "fieldname": "send_email_notifications", @@ -1334,7 +1451,10 @@ "fieldtype": "Check", "label": "Disable Frappe Auth" }, - { "fieldname": "section_break_nloq", "fieldtype": "Section Break" }, + { + "fieldname": "section_break_nloq", + "fieldtype": "Section Break" + }, { "fieldname": "servers_using_alternative_http_port_for_communication", "fieldtype": "Small Text", @@ -1393,8 +1513,15 @@ "fieldtype": "Data", "label": "Drive Resource Link" }, - { "fieldname": "ic_key", "fieldtype": "Password", "label": "IC Key" }, - { "fieldname": "section_break_dhzi", "fieldtype": "Section Break" }, + { + "fieldname": "ic_key", + "fieldtype": "Password", + "label": "IC Key" + }, + { + "fieldname": "section_break_dhzi", + "fieldtype": "Section Break" + }, { "fieldname": "auto_scale_section", "fieldtype": "Section Break", @@ -1451,13 +1578,20 @@ "fieldtype": "Section Break", "label": "Frappe School Authentication" }, - { "fieldname": "school_url", "fieldtype": "Data", "label": "School URL" }, + { + "fieldname": "school_url", + "fieldtype": "Data", + "label": "School URL" + }, { "fieldname": "school_api_key", "fieldtype": "Data", "label": "School API Key" }, - { "fieldname": "column_break_uxxz", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_uxxz", + "fieldtype": "Column Break" + }, { "fieldname": "school_api_secret", "fieldtype": "Password", @@ -1529,34 +1663,14 @@ "label": "Use New Deploy Flow" }, { - "fieldname": "plans_section", + "fieldname": "chatwoot_section", "fieldtype": "Section Break", - "label": "Plans" + "label": "Chat Support" }, { - "fieldname": "column_break_kujg", + "fieldname": "column_break_srse", "fieldtype": "Column Break" }, - { - "default": "30", - "description": "Default number of days before the site warranty setting can be changed for newly created dedicated servers", - "fieldname": "default_dedicated_server_site_warranty_change_cooldown", - "fieldtype": "Int", - "label": "Default Dedicated Server Site Warranty change Cooldown" - }, - { - "default": "5", - "description": "Default max number of sites with product warranty for newly created dedicated servers\n", - "fieldname": "default_dedicated_server_site_warranty_quota", - "fieldtype": "Int", - "label": "Default Dedicated Server Supported Site Quota" - }, - { - "fieldname": "chatwoot_section", - "fieldtype": "Section Break", - "label": "Chat Support" - }, - { "fieldname": "column_break_srse", "fieldtype": "Column Break" }, { "default": "0", "fieldname": "enable_chat", @@ -1587,7 +1701,11 @@ "label": "Chat Support End Time", "options": "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n21\n22\n23\n24" }, - { "fieldname": "region_name", "fieldtype": "Data", "label": "Region Name" }, + { + "fieldname": "region_name", + "fieldtype": "Data", + "label": "Region Name" + }, { "default": "0", "fieldname": "auto_upgrade_dependencies", @@ -1595,15 +1713,14 @@ "label": "Auto Upgrade Dependencies" }, { - "default": 0, - "fieldname": "push_feature", - "fieldtype": "Check", - "label": "Push Feature" + "fieldname": "secret", + "fieldtype": "Password", + "label": "Secret" } ], "issingle": 1, "links": [], - "modified": "2026-04-30 18:44:43.851468", + "modified": "2026-05-24 19:40:21.224827", "modified_by": "Administrator", "module": "Press", "name": "Press Settings", diff --git a/press/press/doctype/press_settings/press_settings.py b/press/press/doctype/press_settings/press_settings.py index 789ec94b652..92230f8e00d 100644 --- a/press/press/doctype/press_settings/press_settings.py +++ b/press/press/doctype/press_settings/press_settings.py @@ -114,8 +114,6 @@ class PressSettings(Document): cool_off_period: DF.Int data_40: DF.Data | None default_apps: DF.Table[AppGroup] - default_dedicated_server_site_warranty_change_cooldown: DF.Int - default_dedicated_server_site_warranty_quota: DF.Int default_outgoing_id: DF.Data | None default_outgoing_pass: DF.Data | None default_server_plan_type: DF.Link | None @@ -198,7 +196,6 @@ class PressSettings(Document): print_format: DF.Data | None production_server_ip: DF.Data | None publish_docs: DF.Check - push_feature: DF.Check razorpay_key_id: DF.Data | None razorpay_key_secret: DF.Password | None razorpay_webhook_secret: DF.Data | None @@ -214,6 +211,7 @@ class PressSettings(Document): school_api_key: DF.Data | None school_api_secret: DF.Password | None school_url: DF.Data | None + secret: DF.Password | None send_email_notifications: DF.Check send_telegram_notifications: DF.Check servers_using_alternative_http_port_for_communication: DF.SmallText | None diff --git a/press/press/doctype/proxy_server/proxy_server.js b/press/press/doctype/proxy_server/proxy_server.js index b641e1c53e1..f1c5a01065d 100644 --- a/press/press/doctype/proxy_server/proxy_server.js +++ b/press/press/doctype/proxy_server/proxy_server.js @@ -3,7 +3,7 @@ frappe.ui.form.on('Proxy Server', { refresh: function (frm) { - [ + ;[ [__('Ping Agent'), 'ping_agent', false, frm.doc.is_server_setup], [__('Ping Ansible'), 'ping_ansible', true], [__('Ping Ansible Unprepared'), 'ping_ansible_unprepared', true], @@ -15,7 +15,11 @@ frappe.ui.form.on('Proxy Server', { frm.doc.is_server_setup, ], [ - __('Setup Agent Auth'), + __( + frm.doc.is_agent_auth_setup + ? 'Regenerate Agent Token' + : 'Setup Agent Auth', + ), 'setup_agent_auth', false, frm.doc.is_server_setup, @@ -119,34 +123,34 @@ frappe.ui.form.on('Proxy Server', { () => frm.call(method).then((r) => { if (r.message) { - frappe.msgprint(r.message); + frappe.msgprint(r.message) } else { - frm.refresh(); + frm.refresh() } }), - ); + ) } else { frm.call(method).then((r) => { if (r.message) { - frappe.msgprint(r.message); + frappe.msgprint(r.message) } else { - frm.refresh(); + frm.refresh() } - }); + }) } }, __('Actions'), - ); + ) } - }); + }) if (frm.doc.is_server_setup) { frm.add_custom_button( __('Update Memory Limits'), () => { - let process_options = ['', 'nginx', 'filebeat']; - frm.doc.is_proxysql_setup && process_options.push('proxysql'); - frm.doc.is_ssh_proxy_setup && process_options.push('ssh'); + let process_options = ['', 'nginx', 'filebeat'] + frm.doc.is_proxysql_setup && process_options.push('proxysql') + frm.doc.is_ssh_proxy_setup && process_options.push('ssh') const dialog = new frappe.ui.Dialog({ title: 'Set Memory Limits', @@ -193,12 +197,12 @@ frappe.ui.form.on('Proxy Server', { frm .call('set_memory_limits', { limits: values.process_table }) .then((r) => { - frappe.show_alert(r.message); - dialog.hide(); - }); + frappe.show_alert(r.message) + dialog.hide() + }) }, - }); - dialog.show(); + }) + dialog.show() frm.call('get_memory_limits').then((r) => { if (r.message) { @@ -207,18 +211,18 @@ frappe.ui.form.on('Proxy Server', { process: limit.process, memory_high: limit.memory_high, memory_max: limit.memory_max, - }); - }); + }) + }) } - dialog.fields_dict.process_table.grid.refresh(); - }); + dialog.fields_dict.process_table.grid.refresh() + }) }, __('Actions'), - ); + ) } }, hostname: function (frm) { - press.set_hostname_abbreviation(frm); + press.set_hostname_abbreviation(frm) }, -}); +}) diff --git a/press/press/doctype/proxy_server/proxy_server.json b/press/press/doctype/proxy_server/proxy_server.json index a70c5f80b92..bcc97d87d17 100644 --- a/press/press/doctype/proxy_server/proxy_server.json +++ b/press/press/doctype/proxy_server/proxy_server.json @@ -36,6 +36,8 @@ "private_vlan_id", "agent_section", "agent_password", + "is_agent_auth_setup", + "agent_job_update_feature", "column_break_mznm", "disable_agent_job_auto_retry", "use_as_proxy_for_agent_and_metrics", @@ -112,7 +114,10 @@ "label": "Server Setup", "read_only": 1 }, - { "fieldname": "column_break_3", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, { "fieldname": "agent_section", "fieldtype": "Section Break", @@ -135,7 +140,10 @@ "label": "Frappe Public Key", "read_only": 1 }, - { "fieldname": "column_break_10", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_10", + "fieldtype": "Column Break" + }, { "fieldname": "cluster", "fieldtype": "Link", @@ -188,7 +196,10 @@ "mandatory_depends_on": "eval: doc.provider === \"Scaleway\"", "set_only_once": 1 }, - { "fieldname": "column_break_18", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_18", + "fieldtype": "Column Break" + }, { "fieldname": "frappe_user_password", "fieldtype": "Password", @@ -225,7 +236,10 @@ "label": "SSH Certificate Authority", "options": "SSH Certificate Authority" }, - { "fieldname": "column_break_26", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_26", + "fieldtype": "Column Break" + }, { "default": "0", "fieldname": "is_ssh_proxy_setup", @@ -317,7 +331,11 @@ "fieldtype": "Data", "label": "Self Hosted Server Domain" }, - { "fieldname": "vpn_tab", "fieldtype": "Tab Break", "label": "VPN" }, + { + "fieldname": "vpn_tab", + "fieldtype": "Tab Break", + "label": "VPN" + }, { "default": "51820", "fieldname": "wireguard_port", @@ -361,7 +379,10 @@ "fieldtype": "Data", "label": "Wireguard Network IP" }, - { "fieldname": "column_break_dapz", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_dapz", + "fieldtype": "Column Break" + }, { "fieldname": "hostname_abbreviation", "fieldtype": "Data", @@ -375,7 +396,10 @@ "label": "Enabled Default Routing", "read_only": 1 }, - { "fieldname": "column_break_mznm", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_mznm", + "fieldtype": "Column Break" + }, { "default": "1", "fieldname": "disable_agent_job_auto_retry", @@ -473,10 +497,23 @@ "fieldname": "use_as_proxy_for_agent_and_metrics", "fieldtype": "Check", "label": "Use as Proxy for Agent and Metrics" + }, + { + "default": "0", + "fieldname": "is_agent_auth_setup", + "fieldtype": "Check", + "label": "Is Agent Auth Setup", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "agent_job_update_feature", + "fieldtype": "Check", + "label": "Agent Job Update Feature" } ], "links": [], - "modified": "2026-03-15 19:01:43.557830", + "modified": "2026-05-24 18:50:03.126562", "modified_by": "Administrator", "module": "Press", "name": "Proxy Server", diff --git a/press/press/doctype/proxy_server/proxy_server.py b/press/press/doctype/proxy_server/proxy_server.py index a19142fd096..fc5807a34c4 100644 --- a/press/press/doctype/proxy_server/proxy_server.py +++ b/press/press/doctype/proxy_server/proxy_server.py @@ -24,6 +24,7 @@ class ProxyServer(BaseServer): from press.press.doctype.proxy_server_domain.proxy_server_domain import ProxyServerDomain + agent_job_update_feature: DF.Check agent_password: DF.Password | None auto_add_storage_max: DF.Int auto_add_storage_min: DF.Int @@ -41,6 +42,7 @@ class ProxyServer(BaseServer): hostname: DF.Data hostname_abbreviation: DF.Data | None ip: DF.Data | None + is_agent_auth_setup: DF.Check is_primary: DF.Check is_proxysql_setup: DF.Check is_replication_setup: DF.Check @@ -80,6 +82,10 @@ class ProxyServer(BaseServer): wireguard_public_key: DF.Password | None # end: auto-generated types + def on_update(self): + if self.has_value_changed("agent_job_update_feature"): + self.update_feature(self.agent_job_update_feature) + def validate(self): super().validate() self.validate_domains() @@ -126,9 +132,8 @@ def _setup_server(self): else: kibana_password = None - private_key = self._generate_and_activate_key() - agent_token = self.sign_agent_token(private_key) - auth = self.agent_auth + secret = self._generate_secret() + agent_token = self.sign_agent_token(secret) try: ansible = Ansible( @@ -157,8 +162,7 @@ def _setup_server(self): if play.status == "Success": self.status = "Active" self.is_server_setup = True - auth.is_agent_auth_setup = 1 - auth.save(ignore_permissions=True) + self.is_agent_auth_setup = 1 else: self.status = "Broken" except Exception: diff --git a/press/press/doctype/proxy_server/test_proxy_server.py b/press/press/doctype/proxy_server/test_proxy_server.py index 9d89c3e7484..1dfa545c9d5 100644 --- a/press/press/doctype/proxy_server/test_proxy_server.py +++ b/press/press/doctype/proxy_server/test_proxy_server.py @@ -64,55 +64,3 @@ def test_failover_document_creation(self): self.assertTrue( frappe.db.exists("Proxy Failover", {"primary": proxy1.name, "secondary": proxy2.name}) ) - - @patch("press.press.doctype.proxy_server.proxy_server.Ansible") - def test_setup_server_marks_agent_auth_as_setup( - self, - Mock_Ansible, - ): - server = create_test_proxy_server() - - server.get_password = Mock(return_value="password") - server.get_agent_repository_url = Mock(return_value="repo-url") - - server._generate_and_activate_key = Mock(return_value="private-key") - server.sign_agent_token = Mock(return_value="agent-token") - - auth = frappe._dict( - { - "is_agent_auth_setup": 0, - "save": Mock(), - } - ) - - server.agent_auth = auth - - play = frappe._dict({"status": "Success"}) - Mock_Ansible.return_value.run.return_value = play - - with patch("frappe.get_doc") as mock_get_doc: - mock_get_doc.return_value = frappe._dict( - { - "private_key": "key", # pragma: allowlist secret - "full_chain": "chain", - "intermediate_chain": "intermediate", - "get_password": Mock(return_value="monitoring-password"), - } - ) - - server.save = Mock() - - server._setup_server() - - server._generate_and_activate_key.assert_called_once() - - server.sign_agent_token.assert_called_once_with("private-key") - - self.assertEqual( - auth.is_agent_auth_setup, - 1, - ) - - auth.save.assert_called_once_with( - ignore_permissions=True, - ) diff --git a/press/press/doctype/registry_server/registry_server.js b/press/press/doctype/registry_server/registry_server.js index 903dc9bae66..4ee332f8d69 100644 --- a/press/press/doctype/registry_server/registry_server.js +++ b/press/press/doctype/registry_server/registry_server.js @@ -3,13 +3,17 @@ frappe.ui.form.on('Registry Server', { refresh: function (frm) { - [ + ;[ [__('Ping Ansible'), 'ping_ansible', true], [__('Ping Ansible Unprepared'), 'ping_ansible_unprepared', true], [__('Prepare Server'), 'prepare_server', true, !frm.doc.is_server_setup], [__('Setup Server'), 'setup_server', true, !frm.doc.is_server_setup], [ - __('Setup Agent Auth'), + __( + frm.doc.is_agent_auth_setup + ? 'Regenerate Agent Token' + : 'Setup Agent Auth', + ), 'setup_agent_auth', false, frm.doc.is_server_setup, @@ -54,24 +58,24 @@ frappe.ui.form.on('Registry Server', { () => frm.call(method).then((r) => { if (r.message) { - frappe.msgprint(r.message); + frappe.msgprint(r.message) } else { - frm.refresh(); + frm.refresh() } }), - ); + ) } else { frm.call(method).then((r) => { if (r.message) { - frappe.msgprint(r.message); + frappe.msgprint(r.message) } else { - frm.refresh(); + frm.refresh() } - }); + }) } }, __('Actions'), - ); + ) } if (method == 'create_registry_mirror') { frm.add_custom_button( @@ -133,14 +137,14 @@ frappe.ui.form.on('Registry Server', { private_ip, proxy_pass, }) - .then((r) => frm.refresh()); + .then((r) => frm.refresh()) }, __('Create Mirror Registry'), - ); + ) }, __('Actions'), - ); + ) } - }); + }) }, -}); +}) diff --git a/press/press/doctype/registry_server/registry_server.json b/press/press/doctype/registry_server/registry_server.json index 5150449876a..a9257c8b645 100644 --- a/press/press/doctype/registry_server/registry_server.json +++ b/press/press/doctype/registry_server/registry_server.json @@ -23,6 +23,8 @@ "private_vlan_id", "agent_section", "agent_password", + "is_agent_auth_setup", + "agent_job_update_feature", "registry_section", "registry_username", "docker_data_mountpoint", @@ -123,15 +125,24 @@ "label": "Root Public Key", "read_only": 1 }, - { "fieldname": "column_break_20", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_20", + "fieldtype": "Column Break" + }, { "fieldname": "frappe_public_key", "fieldtype": "Code", "label": "Frappe Public Key", "read_only": 1 }, - { "fieldname": "column_break_10", "fieldtype": "Column Break" }, - { "fieldname": "column_break_4", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_10", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, { "default": "Generic", "fieldname": "provider", @@ -151,7 +162,10 @@ "fieldtype": "Section Break", "label": "Networking" }, - { "fieldname": "column_break_9", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, { "depends_on": "eval: doc.provider === \"Scaleway\"", "fieldname": "private_mac_address", @@ -197,7 +211,11 @@ "mandatory_depends_on": "eval:doc.provider === \"AWS EC2\"", "options": "Virtual Machine" }, - { "fieldname": "ssh_user", "fieldtype": "Data", "label": "SSH User" }, + { + "fieldname": "ssh_user", + "fieldtype": "Data", + "label": "SSH User" + }, { "default": "22", "fieldname": "ssh_port", @@ -237,18 +255,48 @@ "fieldtype": "Data", "label": "Region Endpoint" }, - { "fieldname": "region", "fieldtype": "Data", "label": "Region" }, - { "fieldname": "bucket_name", "fieldtype": "Data", "label": "Bucket Name" }, - { "fieldname": "proxy_pass", "fieldtype": "Data", "label": "Proxy Pass" }, + { + "fieldname": "region", + "fieldtype": "Data", + "label": "Region" + }, + { + "fieldname": "bucket_name", + "fieldtype": "Data", + "label": "Bucket Name" + }, + { + "fieldname": "proxy_pass", + "fieldtype": "Data", + "label": "Proxy Pass" + }, { "fieldname": "plan", "fieldtype": "Link", "label": "Plan", "options": "Server Plan" + }, + { + "default": "0", + "fieldname": "is_agent_auth_setup", + "fieldtype": "Check", + "label": "Is Agent Auth Setup", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "agent_job_update_feature", + "fieldtype": "Check", + "label": "Agent Job Update Feature" + } + ], + "links": [ + { + "link_doctype": "Ansible Play", + "link_fieldname": "server" } ], - "links": [{ "link_doctype": "Ansible Play", "link_fieldname": "server" }], - "modified": "2025-11-22 15:50:08.907789", + "modified": "2026-05-24 18:49:51.541447", "modified_by": "Administrator", "module": "Press", "name": "Registry Server", diff --git a/press/press/doctype/registry_server/registry_server.py b/press/press/doctype/registry_server/registry_server.py index e6d774d05bb..728b312d032 100644 --- a/press/press/doctype/registry_server/registry_server.py +++ b/press/press/doctype/registry_server/registry_server.py @@ -22,6 +22,7 @@ class RegistryServer(BaseServer): if TYPE_CHECKING: from frappe.types import DF + agent_job_update_feature: DF.Check agent_password: DF.Password | None bucket_name: DF.Data | None container_registry_config_path: DF.Data | None @@ -31,6 +32,7 @@ class RegistryServer(BaseServer): frappe_user_password: DF.Password | None hostname: DF.Data ip: DF.Data + is_agent_auth_setup: DF.Check is_mirror: DF.Check is_server_setup: DF.Check monitoring_password: DF.Password | None @@ -58,6 +60,10 @@ def validate(self): self.validate_registry_password() self.validate_monitoring_password() + def on_update(self): + if self.has_value_changed("agent_job_update_feature"): + self.update_feature(self.agent_job_update_feature) + def validate_registry_password(self): if not self.registry_password: self.registry_password = frappe.generate_hash(length=32) diff --git a/press/press/doctype/server/server.js b/press/press/doctype/server/server.js index cb64ad87af5..1aaf2df3f6b 100644 --- a/press/press/doctype/server/server.js +++ b/press/press/doctype/server/server.js @@ -168,7 +168,11 @@ frappe.ui.form.on('Server', { frm.doc.is_server_setup, ], [ - __('Setup Agent Auth'), + __( + frm.doc.is_agent_auth_setup + ? 'Regenerate Agent Token' + : 'Setup Agent Auth', + ), 'setup_agent_auth', false, frm.doc.is_server_setup, diff --git a/press/press/doctype/server/server.json b/press/press/doctype/server/server.json index fd506ed2993..43a5c7d2284 100644 --- a/press/press/doctype/server/server.json +++ b/press/press/doctype/server/server.json @@ -34,12 +34,9 @@ "is_for_recovery", "is_monitoring_disabled", "enable_on_prem_failover_support", - "stop_incident_actions", "billing_section", "team", "plan", - "site_warranty_change_cooldown", - "supported_site_quota", "column_break_11", "auto_increase_storage", "auto_add_storage_min", @@ -57,6 +54,8 @@ "agent_password", "column_break_pdbx", "disable_agent_job_auto_retry", + "is_agent_auth_setup", + "agent_job_update_feature", "reverse_proxy_section", "proxy_server", "column_break_12", @@ -69,7 +68,6 @@ "column_break_jdiy", "self_hosted_mariadb_root_password", "managed_database_service", - "db_healthcheck_token", "replication", "is_primary", "is_replication_setup", @@ -227,7 +225,8 @@ "default": "0", "fieldname": "use_for_new_benches", "fieldtype": "Check", - "label": "Use For New Benches" + "label": "Use For New Benches", + "read_only": 1 }, { "fieldname": "hostname", @@ -248,7 +247,8 @@ "default": "0", "fieldname": "use_for_new_sites", "fieldtype": "Check", - "label": "Use For New Sites" + "label": "Use For New Sites", + "read_only": 1 }, { "fieldname": "cluster", @@ -760,32 +760,16 @@ }, { "default": "0", - "fieldname": "stop_incident_actions", + "fieldname": "is_agent_auth_setup", "fieldtype": "Check", - "label": "Stop Incident Actions" - }, - { - "fieldname": "db_healthcheck_token", - "fieldtype": "Password", - "label": "DB Healthcheck Token" - }, - { - "default": "30", - "depends_on": "eval:!doc.__islocal && !doc.public", - "description": "No. of days before product warranty setting can be changed again for sites in this server", - "fieldname": "site_warranty_change_cooldown", - "fieldtype": "Int", - "label": "Site Warranty change Cooldown", - "read_only_depends_on": "eval:!!doc.__islocal" + "label": "Is Agent Auth Setup", + "read_only": 1 }, { - "default": "5", - "depends_on": "eval:!doc.__islocal && !doc.public", - "description": "Max no. of sites with product warranty this server can have", - "fieldname": "supported_site_quota", - "fieldtype": "Int", - "label": "Supported Site Quota", - "read_only_depends_on": "eval:!!doc.__islocal" + "default": "0", + "fieldname": "agent_job_update_feature", + "fieldtype": "Check", + "label": "Agent Job Update Feature" } ], "links": [ @@ -798,7 +782,7 @@ "link_fieldname": "app_server" } ], - "modified": "2026-05-18 10:49:09.702133", + "modified": "2026-05-24 18:45:56.370277", "modified_by": "Administrator", "module": "Press", "name": "Server", @@ -819,12 +803,17 @@ { "create": 1, "read": 1, - "role": "Press User", + "role": "Press Admin", + "write": 1 + }, + { + "create": 1, + "read": 1, + "role": "Press Member", "write": 1 } ], "row_format": "Dynamic", - "rows_threshold_for_grid_search": 20, "sort_field": "modified", "sort_order": "DESC", "states": [], diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index ccaf912c266..53c2bbd5651 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -3,22 +3,22 @@ from __future__ import annotations -import base64 import contextlib import datetime import ipaddress import json import shlex import typing +import uuid from contextlib import suppress from datetime import timedelta from functools import cached_property import boto3 import frappe +import jwt import requests import semantic_version -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from frappe import _ from frappe.core.utils import find, find_all from frappe.installer import subprocess @@ -52,7 +52,6 @@ if typing.TYPE_CHECKING: from press.infrastructure.doctype.arm_build_record.arm_build_record import ARMBuildRecord - from press.press.doctype.agent_auth.agent_auth import AgentAuth from press.press.doctype.agent_job.agent_job import AgentJob from press.press.doctype.ansible_play.ansible_play import AnsiblePlay from press.press.doctype.auto_scale_record.auto_scale_record import AutoScaleRecord @@ -65,6 +64,7 @@ ) from press.press.doctype.on_prem_failover.on_prem_failover import OnPremFailover from press.press.doctype.press_job.press_job import PressJob + from press.press.doctype.press_settings.press_settings import PressSettings from press.press.doctype.release_group.release_group import ReleaseGroup from press.press.doctype.server_mount.server_mount import ServerMount from press.press.doctype.server_plan.server_plan import ServerPlan @@ -703,11 +703,6 @@ def get_agent_repository_branch(self): settings = frappe.get_single("Press Settings") return settings.branch or "master" - @frappe.whitelist() - def regenerate_token(self): - agent_auth: AgentAuth = frappe.get_doc("Agent Auth", self.name) - agent_auth._regenerate_token() - @frappe.whitelist() def ping_agent(self): agent = Agent(self.name, self.doctype) @@ -1248,27 +1243,6 @@ def _create_initial_plan_change(self, plan): } ).insert(ignore_permissions=True) - @cached_property - def agent_auth(self) -> AgentAuth: - name = frappe.db.get_value( - "Agent Auth", - { - "server": self.name, - "server_type": self.doctype, - }, - ) - - if name: - return frappe.get_doc("Agent Auth", name) - - return frappe.get_doc( - { - "doctype": "Agent Auth", - "server": self.name, - "server_type": self.doctype, - } - ).insert(ignore_permissions=True) - @property def subscription(self): name = frappe.db.get_value( @@ -2003,81 +1977,41 @@ def _get_dependency_version(self, candidate: str, dependency: str) -> str: "version", ) - def sign_agent_token(self, private_key: str): - if not private_key: - return None - - auth = self.agent_auth - - expires_in = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=90)).replace( - tzinfo=None - ) + def sign_agent_token(self, secret): + expires_in = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=90) payload = { "server": self.name, - "exp": int(expires_in.timestamp()), # 3 month + "server_type": self.doctype, + "exp": int(expires_in.timestamp()), # 3 months + "jti": str(uuid.uuid4()), } - payload_bytes = json.dumps( - payload, - separators=(",", ":"), - sort_keys=True, - ).encode() + token = jwt.encode(payload, secret, algorithm="HS256") - private_key_obj = Ed25519PrivateKey.from_private_bytes(base64.b64decode(private_key)) - - signature = private_key_obj.sign(payload_bytes) - - token = ( - base64.urlsafe_b64encode(payload_bytes).decode().rstrip("=") - + "." - + base64.urlsafe_b64encode(signature).decode().rstrip("=") - ) - - auth.expires_in = expires_in + if not self.is_agent_auth_setup: + self.db_set("is_agent_auth_setup", 1) return token - def _generate_and_activate_key(self) -> str | None: - from cryptography.hazmat.primitives import serialization - from cryptography.hazmat.primitives.asymmetric import ed25519 - - auth = self.agent_auth - - if auth.public_key and auth.is_agent_auth_setup: - return None - - key = ed25519.Ed25519PrivateKey.generate() - - private_key_bytes = key.private_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PrivateFormat.Raw, - encryption_algorithm=serialization.NoEncryption(), - ) - - private_key_str = base64.b64encode(private_key_bytes).decode() + def _generate_secret(self): + press_settings: PressSettings = frappe.get_single("Press Settings") + secret = press_settings.get_value("secret") - public_key_bytes = key.public_key().public_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PublicFormat.Raw, - ) - - public_key_b64 = base64.b64encode(public_key_bytes).decode() + if secret: + secret = press_settings.get_password("secret") + else: + secret = frappe.generate_hash(length=64) - frappe.cache().delete_key(f"{auth.server}_agent_public_key") - auth.public_key = public_key_b64 + press_settings.secret = secret + press_settings.save(ignore_permissions=True) - return private_key_str + return secret def _setup_agent_auth(self): try: - auth = self.agent_auth - - if auth.public_key and auth.is_agent_auth_setup: - return - - private_key = self._generate_and_activate_key() - agent_token = self.sign_agent_token(private_key) + secret = self._generate_secret() + agent_token = self.sign_agent_token(secret) ansible = Ansible( playbook="setup_agent_auth.yml", @@ -2091,9 +2025,6 @@ def _setup_agent_auth(self): log_error("Agent auth setup playbook failed", server=self.as_dict()) return - auth.is_agent_auth_setup = 1 - - auth.save(ignore_permissions=True) except Exception: log_error("Agent Auth Setup Exception", server=self.as_dict()) @@ -2883,6 +2814,13 @@ def _migrate_to_cgroup_v2(self): except Exception: log_error("Cgroup v2 Migration Exception", server=self.as_dict()) + def update_feature(self, flag: bool): + agent = Agent(self.name, self.doctype) + if flag: + agent.enable_feature_flag() + else: + agent.disable_feature_flag() + class Server(BaseServer): # begin: auto-generated types @@ -2898,6 +2836,7 @@ class Server(BaseServer): from press.press.doctype.resource_tag.resource_tag import ResourceTag from press.press.doctype.server_mount.server_mount import ServerMount + agent_job_update_feature: DF.Check agent_password: DF.Password | None auto_add_storage_max: DF.Int auto_add_storage_min: DF.Int @@ -2908,7 +2847,6 @@ class Server(BaseServer): cluster: DF.Link | None communication_infos: DF.Table[CommunicationInfo] database_server: DF.Link | None - db_healthcheck_token: DF.Password | None disable_agent_job_auto_retry: DF.Check domain: DF.Link | None enable_logical_replication_during_site_update: DF.Check @@ -2922,6 +2860,7 @@ class Server(BaseServer): ignore_incidents_till: DF.Datetime | None ip: DF.Data | None ipv6: DF.Data | None + is_agent_auth_setup: DF.Check is_for_recovery: DF.Check is_managed_database: DF.Check is_monitoring_disabled: DF.Check @@ -2963,15 +2902,12 @@ class Server(BaseServer): self_hosted_mariadb_server: DF.Data | None self_hosted_server_domain: DF.Data | None set_bench_memory_limits: DF.Check - site_warranty_change_cooldown: DF.Int skip_scheduled_backups: DF.Check ssh_port: DF.Int ssh_user: DF.Data | None staging: DF.Check status: DF.Literal["Pending", "Installing", "Active", "Broken", "Archived"] stop_deployments: DF.Check - stop_incident_actions: DF.Check - supported_site_quota: DF.Int tags: DF.Table[ResourceTag] team: DF.Link | None title: DF.Data | None @@ -3061,6 +2997,9 @@ def on_update(self): if self.is_new() and is_dedicated_server(self.name): self.set_dedicated_server_site_warranty_quota_and_cooldown() + if self.has_value_changed("agent_job_update_feature"): + self.update_feature(self.agent_job_update_feature) + def update_db_server(self): if not self.database_server: return @@ -3349,9 +3288,8 @@ def _setup_server(self): certificate = self.get_certificate() log_server, kibana_password = self.get_log_server() agent_sentry_dsn = frappe.db.get_single_value("Press Settings", "agent_sentry_dsn") - private_key = self._generate_and_activate_key() - agent_token = self.sign_agent_token(private_key) - auth = self.agent_auth + secret = self._generate_secret() + agent_token = self.sign_agent_token(secret) # If database server is set, then define db port under configuration db_port = ( @@ -3396,8 +3334,7 @@ def _setup_server(self): if play.status == "Success": self.status = "Active" self.is_server_setup = True - auth.is_agent_auth_setup = 1 - auth.save(ignore_permissions=True) + self.is_agent_auth_setup = 1 if self.provider == "DigitalOcean": # To adjust docker permissions self.reboot() diff --git a/press/press/doctype/server/test_server.py b/press/press/doctype/server/test_server.py index 67276d406c7..8fc7f0a5e78 100644 --- a/press/press/doctype/server/test_server.py +++ b/press/press/doctype/server/test_server.py @@ -383,139 +383,3 @@ def test_server_with_more_memory_is_shortlisted_for_new_benches_and_incident_cre ) self.assertEqual(len(incidents), 1) self.assertEqual(incidents[0].server, self.high_mem_server.name) - - @patch("press.runner.Ansible.run") - @patch.object(BaseServer, "sign_agent_token") - @patch.object(BaseServer, "_generate_and_activate_key") - def test_setup_agent_auth_returns_when_already_setup( - self, - mock_generate_key, - mock_sign_token, - mock_ansible_run, - ): - server = create_test_server() - - auth = server.agent_auth - auth.public_key = "public-key" - auth.is_agent_auth_setup = 1 - auth.save(ignore_permissions=True) - - server._setup_agent_auth() - - mock_generate_key.assert_not_called() - mock_sign_token.assert_not_called() - mock_ansible_run.assert_not_called() - - def test_generate_and_activate_key_returns_none_when_already_setup(self): - server = create_test_server() - - auth = server.agent_auth - auth.public_key = "public-key" - auth.is_agent_auth_setup = 1 - - result = server._generate_and_activate_key() - - self.assertIsNone(result) - - def test_sign_agent_token_returns_none_without_private_key(self): - server = create_test_server() - - token = server.sign_agent_token(None) - - self.assertIsNone(token) - - def test_sign_agent_token_sets_expiry_and_returns_token(self): - server = create_test_server() - - private_key = server._generate_and_activate_key() - - token = server.sign_agent_token(private_key) - - self.assertIsNotNone(token) - - self.assertIn(".", token) - - self.assertIsNotNone(server.agent_auth.expires_in) - - @patch("press.runner.Ansible.run") - @patch.object(BaseServer, "sign_agent_token") - @patch.object(BaseServer, "_generate_and_activate_key") - def test_setup_agent_auth_marks_auth_as_setup_on_success( - self, - mock_generate_key, - mock_sign_token, - mock_ansible_run, - ): - server = create_test_server() - - mock_generate_key.return_value = "private-key" - mock_sign_token.return_value = "token" - - play = frappe._dict({"status": "Success"}) - mock_ansible_run.return_value = play - - auth = server.agent_auth - auth.save = Mock() - - server._setup_agent_auth() - - mock_generate_key.assert_called_once() - - mock_sign_token.assert_called_once_with("private-key") - - self.assertEqual( - auth.is_agent_auth_setup, - 1, - ) - - auth.save.assert_called_once_with( - ignore_permissions=True, - ) - - @patch("press.press.doctype.server.server.log_error") - @patch("press.runner.Ansible.run") - @patch.object(BaseServer, "sign_agent_token") - @patch.object(BaseServer, "_generate_and_activate_key") - def test_setup_agent_auth_logs_error_on_failed_playbook( - self, - mock_generate_key, - mock_sign_token, - mock_ansible_run, - mock_log_error, - ): - server = create_test_server() - - mock_generate_key.return_value = "private-key" - mock_sign_token.return_value = "token" - - play = frappe._dict({"status": "Failure"}) - mock_ansible_run.return_value = play - - auth = server.agent_auth - auth.save = Mock() - - server._setup_agent_auth() - - self.assertEqual( - auth.is_agent_auth_setup, - 0, - ) - - auth.save.assert_not_called() - - mock_log_error.assert_called_once() - - @patch("press.press.doctype.server.server.log_error") - @patch.object(BaseServer, "_generate_and_activate_key") - def test_setup_agent_auth_logs_error_on_exception( - self, - mock_generate_key, - mock_log_error, - ): - server = create_test_server() - - mock_generate_key.side_effect = Exception() - - server._setup_agent_auth() - - mock_log_error.assert_called_once() diff --git a/press/press/doctype/trace_server/trace_server.json b/press/press/doctype/trace_server/trace_server.json index 67a45d51e54..ba15fb23df5 100644 --- a/press/press/doctype/trace_server/trace_server.json +++ b/press/press/doctype/trace_server/trace_server.json @@ -21,6 +21,8 @@ "private_vlan_id", "agent_section", "agent_password", + "is_agent_auth_setup", + "agent_job_update_feature", "ssh_section", "frappe_user_password", "frappe_public_key", @@ -69,7 +71,10 @@ "read_only": 1, "set_only_once": 1 }, - { "fieldname": "column_break_4", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, { "default": "Generic", "fieldname": "provider", @@ -107,7 +112,10 @@ "reqd": 1, "set_only_once": 1 }, - { "fieldname": "column_break_10", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_10", + "fieldtype": "Column Break" + }, { "fetch_from": "virtual_machine.private_ip_address", "fieldname": "private_ip", @@ -160,7 +168,10 @@ "label": "Frappe Public Key", "read_only": 1 }, - { "fieldname": "column_break_19", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_19", + "fieldtype": "Column Break" + }, { "fieldname": "root_public_key", "fieldtype": "Code", @@ -195,7 +206,10 @@ "label": "Sentry Admin Password", "set_only_once": 1 }, - { "fieldname": "column_break_27", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_27", + "fieldtype": "Column Break" + }, { "fieldname": "sentry_mail_server", "fieldtype": "Data", @@ -245,18 +259,39 @@ "label": "Sentry OAuth Client Secret", "set_only_once": 1 }, - { "fieldname": "column_break_33", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_33", + "fieldtype": "Column Break" + }, { "default": "0", "fieldname": "tls_certificate_renewal_failed", "fieldtype": "Check", "label": "TLS Certificate Renewal Failed", "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_agent_auth_setup", + "fieldtype": "Check", + "label": "Is Agent Auth Setup", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "agent_job_update_feature", + "fieldtype": "Check", + "label": "Agent Job Update Feature" } ], "grid_page_length": 50, - "links": [{ "link_doctype": "Ansible Play", "link_fieldname": "server" }], - "modified": "2025-09-02 16:44:01.317526", + "links": [ + { + "link_doctype": "Ansible Play", + "link_fieldname": "server" + } + ], + "modified": "2026-05-24 19:38:30.874592", "modified_by": "Administrator", "module": "Press", "name": "Trace Server", diff --git a/press/press/doctype/trace_server/trace_server.py b/press/press/doctype/trace_server/trace_server.py index 1a5a64fc04f..63f586100b4 100644 --- a/press/press/doctype/trace_server/trace_server.py +++ b/press/press/doctype/trace_server/trace_server.py @@ -17,12 +17,14 @@ class TraceServer(BaseServer): if TYPE_CHECKING: from frappe.types import DF + agent_job_update_feature: DF.Check agent_password: DF.Password | None domain: DF.Link | None frappe_public_key: DF.Code | None frappe_user_password: DF.Password | None hostname: DF.Data ip: DF.Data + is_agent_auth_setup: DF.Check is_server_setup: DF.Check monitoring_password: DF.Password | None private_ip: DF.Data diff --git a/press/tests/test_agent.py b/press/tests/test_agent.py index 224b3ee1a3c..72b0233cb6a 100644 --- a/press/tests/test_agent.py +++ b/press/tests/test_agent.py @@ -1,10 +1,6 @@ # Copyright (c) 2024, Frappe and contributors # For license information, please see license.txt -import base64 -import json -import time -from unittest.mock import Mock, patch import frappe import requests @@ -127,179 +123,3 @@ def test_remove_function_removes_failure_if_ping_succeeds(self): responses.assert_call_count(f"https://{server.name}:443/agent/ping", 1) self.assertEqual(frappe.db.count("Agent Request Failure", {"server": server.name}), 0) - - @patch.object(Agent, "get_agent_public_key") - @patch.object(Agent, "get_regenerate_public_key") - def test_verify_request_token_fails_without_public_keys( - self, - mock_regenerate_key, - mock_public_key, - ): - mock_public_key.return_value = None - mock_regenerate_key.return_value = None - - agent = Agent("test-server") - - self.assertRaises( - ValueError, - agent._verify_request_token, - "abc.def", - ) - - def test_verify_request_token_fails_for_malformed_token(self): - agent = Agent("test-server") - - self.assertRaises( - ValueError, - agent._verify_request_token, - "invalid-token", - ) - - @patch.object(Agent, "_verify_request_token") - def test_extract_and_verify_token_raises_permission_error( - self, - mock_verify, - ): - mock_verify.side_effect = ValueError() - - agent = Agent("test-server") - - self.assertRaises( - frappe.PermissionError, - agent.extract_and_verify_token, - "token", - ) - - def test_get_agent_public_key_returns_cached_key(self): - agent = Agent("test-server") - - frappe.cache().set_value( - "test-server_agent_public_key", - "cached-key", - ) - - self.assertEqual( - agent.get_agent_public_key(), - "cached-key", - ) - - def test_get_agent_public_key_returns_none_when_agent_auth_missing(self): - agent = Agent("test-server") - - with patch( - "frappe.get_doc", - side_effect=frappe.DoesNotExistError, - ): - self.assertIsNone(agent.get_agent_public_key()) - - def test_get_regenerate_public_key_clears_db_flag(self): - agent = Agent("test-server") - - agent_auth = frappe._dict( - { - "regenerate_public_key": "old-key", - "save": Mock(), - } - ) - - with patch( - "frappe.get_doc", - return_value=agent_auth, - ): - result = agent.get_regenerate_public_key() - - self.assertIsNone(result) - - self.assertIsNone( - agent_auth.regenerate_public_key, - ) - - agent_auth.save.assert_called_once_with( - ignore_permissions=True, - ) - - @patch.object(Agent, "_is_token_verified") - @patch.object(Agent, "get_regenerate_public_key") - @patch.object(Agent, "get_agent_public_key") - def test_verify_request_token_fails_for_invalid_server( - self, - mock_public_key, - mock_regenerate_key, - mock_verify, - ): - mock_public_key.return_value = "public-key" - mock_regenerate_key.return_value = None - mock_verify.return_value = True - - payload = ( - base64.urlsafe_b64encode( - json.dumps( - { - "server": "wrong-server", - "exp": int(time.time()) + 1000, - } - ).encode() - ) - .decode() - .rstrip("=") - ) - - signature = ( - base64.urlsafe_b64encode( - b"signature", - ) - .decode() - .rstrip("=") - ) - - token = f"{payload}.{signature}" - - agent = Agent("test-server") - - self.assertRaises( - ValueError, - agent._verify_request_token, - token, - ) - - @patch.object(Agent, "_is_token_verified") - @patch.object(Agent, "get_regenerate_public_key") - @patch.object(Agent, "get_agent_public_key") - def test_verify_request_token_succeeds( - self, - mock_public_key, - mock_regenerate_key, - mock_verify, - ): - mock_public_key.return_value = "public-key" - mock_regenerate_key.return_value = None - mock_verify.return_value = True - - payload = ( - base64.urlsafe_b64encode( - json.dumps( - { - "server": "test-server", - "exp": int(time.time()) + 1000, - } - ).encode() - ) - .decode() - .rstrip("=") - ) - - signature = ( - base64.urlsafe_b64encode( - b"signature", - ) - .decode() - .rstrip("=") - ) - - token = f"{payload}.{signature}" - - agent = Agent("test-server") - - self.assertTrue( - agent._verify_request_token(token), - ) From eb2ab5933abe15f96aecd722676760e673e66060 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sun, 24 May 2026 14:49:16 +0000 Subject: [PATCH 48/59] fix(ruff): Fix ruff issues --- press/api/server.py | 2 +- press/playbooks/database.yml | 1 - press/playbooks/proxy.yml | 1 - press/playbooks/self_hosted.yml | 1 - press/playbooks/self_hosted_db.yml | 1 - press/playbooks/self_hosted_proxy.yml | 1 - press/playbooks/server.yml | 1 - press/press/doctype/database_server/database_server.json | 1 - press/press/doctype/server/server.py | 4 ++-- 9 files changed, 3 insertions(+), 10 deletions(-) diff --git a/press/api/server.py b/press/api/server.py index 50c8a157188..5e486d2bec9 100644 --- a/press/api/server.py +++ b/press/api/server.py @@ -13,8 +13,8 @@ from frappe.utils.caching import redis_cache from frappe.utils.password import get_decrypted_password -from press.api.agent_auth import verify_agent from press.api.account import is_limits_exceeded +from press.api.agent_auth import verify_agent from press.api.analytics import auto_timespan_timegrain, get_rounded_boundaries, get_rounded_boundary from press.api.bench import all as all_benches from press.api.site import protected diff --git a/press/playbooks/database.yml b/press/playbooks/database.yml index 990c4e00975..fc13c738dfe 100644 --- a/press/playbooks/database.yml +++ b/press/playbooks/database.yml @@ -13,7 +13,6 @@ - role: mariadb_memory_allocator - role: nginx - role: agent - - role: setup_agent_auth - role: node_exporter - role: mysqld_exporter - role: deadlock_logger diff --git a/press/playbooks/proxy.yml b/press/playbooks/proxy.yml index f7ef593876c..6624eafa5c8 100644 --- a/press/playbooks/proxy.yml +++ b/press/playbooks/proxy.yml @@ -9,7 +9,6 @@ - role: user - role: nginx - role: agent - - role: setup_agent_auth - role: proxy - role: node_exporter - role: user_ssh_certificate diff --git a/press/playbooks/self_hosted.yml b/press/playbooks/self_hosted.yml index 42e56ddb64a..541dffd3380 100644 --- a/press/playbooks/self_hosted.yml +++ b/press/playbooks/self_hosted.yml @@ -9,7 +9,6 @@ - role: user - role: nginx - role: agent - - role: setup_agent_auth - role: bench - role: docker - role: node_exporter diff --git a/press/playbooks/self_hosted_db.yml b/press/playbooks/self_hosted_db.yml index b9291e67923..8d2aa491a37 100644 --- a/press/playbooks/self_hosted_db.yml +++ b/press/playbooks/self_hosted_db.yml @@ -10,7 +10,6 @@ - role: mariadb - role: nginx - role: agent - - role: setup_agent_auth - role: node_exporter - role: mysqld_exporter - role: deadlock_logger diff --git a/press/playbooks/self_hosted_proxy.yml b/press/playbooks/self_hosted_proxy.yml index 4413b31f069..9754455dfbb 100644 --- a/press/playbooks/self_hosted_proxy.yml +++ b/press/playbooks/self_hosted_proxy.yml @@ -9,6 +9,5 @@ - role: user - role: nginx - role: agent - - role: setup_agent_auth - role: proxy - role: docker diff --git a/press/playbooks/server.yml b/press/playbooks/server.yml index 40e8a4725cd..12daffa5a80 100644 --- a/press/playbooks/server.yml +++ b/press/playbooks/server.yml @@ -10,7 +10,6 @@ - role: user - role: nginx - role: agent - - role: setup_agent_auth - role: mount - role: bench - role: docker diff --git a/press/press/doctype/database_server/database_server.json b/press/press/doctype/database_server/database_server.json index 0612f71a03a..05ef1526222 100644 --- a/press/press/doctype/database_server/database_server.json +++ b/press/press/doctype/database_server/database_server.json @@ -811,7 +811,6 @@ "fieldname": "agent_job_update_feature", "fieldtype": "Check", "label": "Agent Job Update Feature" - "label": "Is External Healthcheck Enabled" }, { "fieldname": "column_break_umtu", diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index e8f77eebea6..f2d85618b02 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -2830,7 +2830,7 @@ def update_feature(self, flag: bool): agent.enable_feature_flag() else: agent.disable_feature_flag() - + def _create_static_ip_log(self): if self.provider != "AWS EC2" or not self.team: return @@ -2963,7 +2963,7 @@ def validate_managed_database_service(self): else: self.managed_database_service = "" - def on_update(self): # noqa: C901 + def on_update(self): # If Database Server is changed for the server then change it for all the benches if self.has_value_changed("database_server") or self.has_value_changed("managed_database_service"): benches = frappe.get_all("Bench", {"server": self.name, "status": ("!=", "Archived")}) From 1997a5af7030b8e248ff76ff2d3e7b916c51af77 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sun, 24 May 2026 16:31:01 +0000 Subject: [PATCH 49/59] feat(agent): Add test cases --- press/agent.py | 28 --- .../press/doctype/agent_job/test_agent_job.py | 176 ++++++++++++++++++ .../database_server/test_database_server.py | 114 ++++++++++++ .../doctype/trace_server/trace_server.js | 26 +-- press/tests/test_agent.py | 95 ++++++++++ 5 files changed, 400 insertions(+), 39 deletions(-) diff --git a/press/agent.py b/press/agent.py index f1ff4797501..255dfe76b24 100644 --- a/press/agent.py +++ b/press/agent.py @@ -950,34 +950,6 @@ def _make_req(self, method, path, data, files, agent_job_id): return requests.request(method, url, headers=headers, files=file_objects, verify=verify) return requests.request(method, url, headers=headers, json=data, verify=verify, timeout=(10, 30)) - def get_agent_public_key(self): - key = f"{self.server}_agent_public_key" - - public_key = frappe.cache().get_value(key) - - if public_key: - return public_key - - try: - agent_auth = frappe.get_doc("Agent Auth", self.server) - except frappe.DoesNotExistError: - return None - - if not agent_auth.public_key: - return None - - public_key = agent_auth.public_key - - if not frappe.cache().get_value(f"{self.server}_regenerate_public_key"): - # Don't set cache while regenerating. Old public key may get cached again. - frappe.cache().set_value( - key, - public_key, - expires_in_sec=3600, - ) - - return public_key - def get_secret(self): key = "agent_auth_secret" diff --git a/press/press/doctype/agent_job/test_agent_job.py b/press/press/doctype/agent_job/test_agent_job.py index fdadcb8ed32..781d5f8bd17 100644 --- a/press/press/doctype/agent_job/test_agent_job.py +++ b/press/press/doctype/agent_job/test_agent_job.py @@ -295,3 +295,179 @@ def test_get_similar_in_execution_job(self): self.assertEqual(in_execution_job.name, job.name) frappe.db.set_single_value("Press Settings", "disable_agent_job_deduplication", True) + + @patch("press.press.doctype.agent_job.agent_job.publish_update") + @patch("press.press.doctype.agent_job.agent_job.process_job_updates") + @patch("press.press.doctype.agent_job.agent_job.skip_pending_steps") + @patch("press.press.doctype.agent_job.agent_job.populate_output_cache") + @patch("press.press.doctype.agent_job.agent_job.update_steps") + @patch("press.press.doctype.agent_job.agent_job.update_job") + @patch("press.press.doctype.agent_job.agent_job.lock_doc_updated_by_job") + @patch("press.press.doctype.agent_job.agent_job.frappe.db.commit", new=Mock()) + def test_handle_polled_job_updates_status( + self, + mock_lock, + mock_update_job, + mock_update_steps, + mock_populate_output_cache, + mock_skip_pending_steps, + mock_process_job_updates, + mock_publish_update, + ): + site = create_test_site() + + job = frappe.get_doc( + { + "doctype": "Agent Job", + "job_type": "New Site", + "server_type": "Server", + "server": site.server, + "site": site.name, + "status": "Pending", + "job_id": 101, + "request_method": "POST", + "request_path": "test/path", + "request_data": "{}", + } + ).insert() + + polled_job = { + "id": 101, + "status": "Success", + "steps": [], + "data": {}, + } + + from press.press.doctype.agent_job.agent_job import handle_polled_job + + handle_polled_job(polled_job=polled_job, job=job) + + mock_lock.assert_called_once_with(job.name) + mock_update_job.assert_called_once_with(job.name, polled_job) + mock_update_steps.assert_called_once_with(job.name, polled_job) + mock_populate_output_cache.assert_called_once_with(polled_job, job) + mock_skip_pending_steps.assert_called_once_with(job.name) + mock_process_job_updates.assert_called_once_with(job.name, polled_job) + mock_publish_update.assert_called_once_with(job.name) + + @patch("press.press.doctype.agent_job.agent_job.publish_update") + @patch("press.press.doctype.agent_job.agent_job.process_job_updates") + @patch("press.press.doctype.agent_job.agent_job.skip_pending_steps") + @patch("press.press.doctype.agent_job.agent_job.populate_output_cache") + @patch("press.press.doctype.agent_job.agent_job.update_steps") + @patch("press.press.doctype.agent_job.agent_job.update_job") + @patch("press.press.doctype.agent_job.agent_job.lock_doc_updated_by_job") + @patch("press.press.doctype.agent_job.agent_job.frappe.db.commit", new=Mock()) + def test_handle_polled_job_does_not_update_if_same_status( + self, + mock_lock, + mock_update_job, + mock_update_steps, + mock_populate_output_cache, + mock_skip_pending_steps, + mock_process_job_updates, + mock_publish_update, + ): + site = create_test_site() + + job = frappe.get_doc( + { + "doctype": "Agent Job", + "job_type": "New Site", + "server_type": "Server", + "server": site.server, + "site": site.name, + "status": "Success", + "job_id": 102, + "request_method": "POST", + "request_path": "test/path", + "request_data": "{}", + } + ).insert() + + polled_job = { + "id": 102, + "status": "Success", + "steps": [], + "data": {}, + } + + from press.press.doctype.agent_job.agent_job import handle_polled_job + + handle_polled_job(polled_job=polled_job, job=job) + + mock_lock.assert_not_called() + mock_update_job.assert_not_called() + + mock_update_steps.assert_called_once_with(job.name, polled_job) + mock_populate_output_cache.assert_called_once_with(polled_job, job) + mock_skip_pending_steps.assert_called_once_with(job.name) + mock_process_job_updates.assert_called_once_with(job.name, polled_job) + mock_publish_update.assert_called_once_with(job.name) + + @patch("frappe.publish_realtime") + def test_publish_update_publishes_realtime_events(self, mock_publish): + site = create_test_site() + + job = frappe.get_doc( + { + "doctype": "Agent Job", + "job_type": "New Site", + "server_type": "Server", + "server": site.server, + "site": site.name, + "status": "Success", + "job_id": 103, + "request_method": "POST", + "request_path": "test/path", + "request_data": "{}", + } + ).insert() + + step = frappe.get_doc( + { + "doctype": "Agent Job Step", + "agent_job": job.name, + "step_name": "Test Step", + "status": "Success", + "duration": "00:00:01", + } + ).insert() + + from press.press.doctype.agent_job.agent_job import publish_update + + publish_update(job.name) + + self.assertTrue(mock_publish.called) + + events = [call.kwargs.get("event") for call in mock_publish.call_args_list] + + self.assertIn("agent_job_update", events) + self.assertIn("doc_update", events) + self.assertIn("list_update", events) + + mock_publish.assert_any_call( + event="agent_job_update", + doctype="Agent Job", + docname=job.name, + message=Mock(), + ) + + mock_publish.assert_any_call( + event="doc_update", + doctype="Agent Job", + docname=job.name, + message={"doctype": "Agent Job", "name": job.name}, + ) + + mock_publish.assert_any_call( + event="list_update", + message={"doctype": "Agent Job", "name": job.name}, + ) + + mock_publish.assert_any_call( + event="doc_update", + doctype="Agent Job Step", + docname=step.name, + message={"doctype": "Agent Job Step", "name": step.name}, + ) diff --git a/press/press/doctype/database_server/test_database_server.py b/press/press/doctype/database_server/test_database_server.py index 08c48ff68b2..2f92e0d943d 100644 --- a/press/press/doctype/database_server/test_database_server.py +++ b/press/press/doctype/database_server/test_database_server.py @@ -150,3 +150,117 @@ def test_adjust_memory_config_sets_memory_limits_with_some_buffer(self): ).value_int, int(15007.248 * 0.65), ) + + @patch( + "press.press.doctype.database_server.database_server.Ansible", + ) + def test_setup_server_marks_server_active_on_success(self, Mock_Ansible): + server = create_test_database_server() + + mock_play = Mock() + mock_play.status = "Success" + + mock_ansible_instance = Mock() + mock_ansible_instance.run.return_value = mock_play + + Mock_Ansible.return_value = mock_ansible_instance + + server._generate_secret = Mock(return_value="secret") + server.sign_agent_token = Mock(return_value="signed-token") + server._set_mount_status = Mock() + server.process_hybrid_server_setup = Mock() + server.reboot = Mock() + + server._setup_server() + + server.reload() + + Mock_Ansible.assert_called_once_with( + playbook="database.yml", + server=server, + user=server.ssh_user or "root", + port=server.ssh_port or 22, + variables={ + "server_type": server.doctype, + "server": server.name, + "workers": "2", + "agent_password": Mock_Ansible.call_args.kwargs["variables"]["agent_password"], + "agent_repository_url": Mock_Ansible.call_args.kwargs["variables"]["agent_repository_url"], + "agent_branch": Mock_Ansible.call_args.kwargs["variables"]["agent_branch"], + "monitoring_password": Mock_Ansible.call_args.kwargs["variables"]["monitoring_password"], + "log_server": Mock_Ansible.call_args.kwargs["variables"]["log_server"], + "kibana_password": Mock_Ansible.call_args.kwargs["variables"]["kibana_password"], + "agent_token": "signed-token", + "private_ip": server.private_ip, + "server_id": server.server_id, + "allocator": server.memory_allocator.lower(), + "db_port": server.db_port or 3306, + "mariadb_root_password": Mock_Ansible.call_args.kwargs["variables"]["mariadb_root_password"], + "certificate_private_key": Mock_Ansible.call_args.kwargs["variables"][ + "certificate_private_key" + ], + "certificate_full_chain": Mock_Ansible.call_args.kwargs["variables"][ + "certificate_full_chain" + ], + "certificate_intermediate_chain": Mock_Ansible.call_args.kwargs["variables"][ + "certificate_intermediate_chain" + ], + "mariadb_depends_on_mounts": server.mariadb_depends_on_mounts, + "nat_gateway_ip": server.get_nat_gateway_ip(), + **server.get_mount_variables(), + }, + ) + + self.assertEqual(server.status, "Active") + self.assertEqual(server.is_server_setup, 1) + self.assertEqual(server.is_agent_auth_setup, 1) + + server.process_hybrid_server_setup.assert_called_once() + server._set_mount_status.assert_called_once_with(mock_play) + + @patch( + "press.press.doctype.database_server.database_server.Ansible", + ) + def test_setup_server_marks_server_broken_on_failed_play(self, Mock_Ansible): + server = create_test_database_server() + + mock_play = Mock() + mock_play.status = "Failure" + + mock_ansible_instance = Mock() + mock_ansible_instance.run.return_value = mock_play + + Mock_Ansible.return_value = mock_ansible_instance + + server._generate_secret = Mock(return_value="secret") + server.sign_agent_token = Mock(return_value="signed-token") + server._set_mount_status = Mock() + + server._setup_server() + + server.reload() + + self.assertEqual(server.status, "Broken") + self.assertEqual(server.is_server_setup, 0) + self.assertEqual(server.is_agent_auth_setup, 0) + + server._set_mount_status.assert_called_once_with(mock_play) + + @patch( + "press.press.doctype.database_server.database_server.Ansible", + ) + def test_setup_server_marks_server_broken_on_exception(self, Mock_Ansible): + server = create_test_database_server() + + Mock_Ansible.side_effect = Exception("Setup failed") + + server._generate_secret = Mock(return_value="secret") + server.sign_agent_token = Mock(return_value="signed-token") + + server._setup_server() + + server.reload() + + self.assertEqual(server.status, "Broken") + self.assertEqual(server.is_server_setup, 0) + self.assertEqual(server.is_agent_auth_setup, 0) diff --git a/press/press/doctype/trace_server/trace_server.js b/press/press/doctype/trace_server/trace_server.js index 935a6b53604..dd5b177b4b4 100644 --- a/press/press/doctype/trace_server/trace_server.js +++ b/press/press/doctype/trace_server/trace_server.js @@ -3,7 +3,7 @@ frappe.ui.form.on('Trace Server', { refresh: function (frm) { - [ + ;[ [__('Ping Agent'), 'ping_agent', false, frm.doc.is_server_setup], [__('Ping Ansible'), 'ping_ansible', true], [__('Ping Ansible Unprepared'), 'ping_ansible_unprepared', true], @@ -31,7 +31,11 @@ frappe.ui.form.on('Trace Server', { frm.doc.is_server_setup, ], [ - __('Setup Agent Auth'), + __( + frm.doc.is_agent_auth_setup + ? 'Regenerate Agent Token' + : 'Setup Agent Auth', + ), 'setup_agent_auth', false, frm.doc.is_server_setup, @@ -47,25 +51,25 @@ frappe.ui.form.on('Trace Server', { () => frm.call(method).then((r) => { if (r.message) { - frappe.msgprint(r.message); + frappe.msgprint(r.message) } else { - frm.refresh(); + frm.refresh() } }), - ); + ) } else { frm.call(method).then((r) => { if (r.message) { - frappe.msgprint(r.message); + frappe.msgprint(r.message) } else { - frm.refresh(); + frm.refresh() } - }); + }) } }, __('Actions'), - ); + ) } - }); + }) }, -}); +}) diff --git a/press/tests/test_agent.py b/press/tests/test_agent.py index 72b0233cb6a..2e4bb8ac895 100644 --- a/press/tests/test_agent.py +++ b/press/tests/test_agent.py @@ -2,7 +2,10 @@ # For license information, please see license.txt +from datetime import datetime, timedelta, timezone + import frappe +import jwt import requests import responses from frappe.tests.utils import FrappeTestCase @@ -123,3 +126,95 @@ def test_remove_function_removes_failure_if_ping_succeeds(self): responses.assert_call_count(f"https://{server.name}:443/agent/ping", 1) self.assertEqual(frappe.db.count("Agent Request Failure", {"server": server.name}), 0) + + def test_get_secret_returns_cached_secret(self): + server = create_test_server() + + agent = Agent(server.name, server.doctype) + + frappe.cache().set_value("agent_auth_secret", "test-secret") + + self.assertEqual(agent.get_secret(), "test-secret") + + def test_get_secret_raises_if_secret_not_configured(self): + server = create_test_server() + + agent = Agent(server.name, server.doctype) + + frappe.cache().delete_value("agent_auth_secret") + + settings = frappe.get_single("Press Settings") + settings.secret = "" + settings.save(ignore_permissions=True) + + self.assertRaises(ValueError, agent.get_secret) + + def test_verify_request_token_success(self): + server = create_test_server() + + agent = Agent(server.name, server.doctype) + + frappe.cache().set_value("agent_auth_secret", "test-secret") + + token = jwt.encode( + { + "server": server.name, + "jti": "123", + "exp": datetime.now(timezone.utc) + timedelta(minutes=5), + }, + "test-secret", + algorithm="HS256", + ) + + self.assertEqual(agent._verify_request_token(token), True) + + def test_verify_request_token_invalid_server(self): + server = create_test_server() + + agent = Agent(server.name, server.doctype) + + frappe.cache().set_value("agent_auth_secret", "test-secret") + + token = jwt.encode( + { + "server": "wrong-server", + "jti": "123", + "exp": datetime.now(timezone.utc) + timedelta(minutes=5), + }, + "test-secret", + algorithm="HS256", + ) + + self.assertRaises(ValueError, agent._verify_request_token, token) + + def test_verify_request_token_expired(self): + server = create_test_server() + + agent = Agent(server.name, server.doctype) + + frappe.cache().set_value("agent_auth_secret", "test-secret") + + token = jwt.encode( + { + "server": server.name, + "jti": "123", + "exp": datetime.now(timezone.utc) - timedelta(minutes=5), + }, + "test-secret", + algorithm="HS256", + ) + + self.assertRaises(ValueError, agent._verify_request_token, token) + + def test_verify_request_token_invalid_token(self): + server = create_test_server() + + agent = Agent(server.name, server.doctype) + + frappe.cache().set_value("agent_auth_secret", "test-secret") + + self.assertRaises( + ValueError, + agent._verify_request_token, + "invalid-token", + ) From 90dec3a6f431eaba45f18f6e3b182f2eedea656c Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sun, 24 May 2026 16:53:21 +0000 Subject: [PATCH 50/59] fix(test): Add update_feature patch --- press/press/doctype/database_server/test_database_server.py | 5 ++++- press/press/doctype/proxy_server/test_proxy_server.py | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/press/press/doctype/database_server/test_database_server.py b/press/press/doctype/database_server/test_database_server.py index 2f92e0d943d..ca005844942 100644 --- a/press/press/doctype/database_server/test_database_server.py +++ b/press/press/doctype/database_server/test_database_server.py @@ -252,7 +252,10 @@ def test_setup_server_marks_server_broken_on_failed_play(self, Mock_Ansible): def test_setup_server_marks_server_broken_on_exception(self, Mock_Ansible): server = create_test_database_server() - Mock_Ansible.side_effect = Exception("Setup failed") + mock_ansible_instance = Mock() + mock_ansible_instance.run.side_effect = Exception("Setup failed") + + Mock_Ansible.return_value = mock_ansible_instance server._generate_secret = Mock(return_value="secret") server.sign_agent_token = Mock(return_value="signed-token") diff --git a/press/press/doctype/proxy_server/test_proxy_server.py b/press/press/doctype/proxy_server/test_proxy_server.py index 1dfa545c9d5..cae0d2c6928 100644 --- a/press/press/doctype/proxy_server/test_proxy_server.py +++ b/press/press/doctype/proxy_server/test_proxy_server.py @@ -18,6 +18,7 @@ @patch.object(BaseServer, "after_insert", new=Mock()) +@patch.object(BaseServer, "update_feature", new=Mock()) @patch.object(ProxyServer, "validate_domains", new=Mock()) def create_test_proxy_server( hostname: str = "n", From 1de501411f51803efdf228044eb9355effa7dce3 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sun, 24 May 2026 17:10:53 +0000 Subject: [PATCH 51/59] fix(server): Ignore update_feature on tests --- press/press/doctype/server/server.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index f2d85618b02..1d53a5b152b 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -2825,6 +2825,9 @@ def _migrate_to_cgroup_v2(self): log_error("Cgroup v2 Migration Exception", server=self.as_dict()) def update_feature(self, flag: bool): + if frappe.flags.in_test: + return + agent = Agent(self.name, self.doctype) if flag: agent.enable_feature_flag() From 3d64c7d6e0f6d4a3841a05ecfd894ede27e7ff82 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sun, 24 May 2026 17:28:34 +0000 Subject: [PATCH 52/59] chore(server): Remove sync_database_server_public_status line --- press/press/doctype/server/server.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index 1d53a5b152b..67857fa1e10 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -2976,8 +2976,6 @@ def on_update(self): bench.managed_database_service = self.managed_database_service bench.save() - self.sync_database_server_public_status() - if self.has_value_changed("team"): self.update_subscription() self.update_db_server() From 3cd7f3d762a510ff6b6a29713e4f1f59d00c86f7 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sun, 24 May 2026 17:58:00 +0000 Subject: [PATCH 53/59] fix(callbacks): Fix test cases --- press/api/tests/test_callbacks.py | 3 ++- press/press/doctype/server/server.json | 33 ++++++++++++++++++++++++++ press/tests/test_agent.py | 3 ++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/press/api/tests/test_callbacks.py b/press/api/tests/test_callbacks.py index 137183274d0..838d303bc20 100644 --- a/press/api/tests/test_callbacks.py +++ b/press/api/tests/test_callbacks.py @@ -1,3 +1,4 @@ +import json from unittest import TestCase from unittest.mock import patch @@ -38,7 +39,7 @@ def test_update_job_calls_handle_polled_job( "job_type": "Deploy", } - update_job(job={"id": "123"}) + update_job(job=json.dumps({"id": "123"})) mock_handle_polled_job.assert_called_once() diff --git a/press/press/doctype/server/server.json b/press/press/doctype/server/server.json index 43a5c7d2284..cbbb9ccf246 100644 --- a/press/press/doctype/server/server.json +++ b/press/press/doctype/server/server.json @@ -34,9 +34,12 @@ "is_for_recovery", "is_monitoring_disabled", "enable_on_prem_failover_support", + "stop_incident_actions", "billing_section", "team", "plan", + "site_warranty_change_cooldown", + "supported_site_quota", "column_break_11", "auto_increase_storage", "auto_add_storage_min", @@ -68,6 +71,7 @@ "column_break_jdiy", "self_hosted_mariadb_root_password", "managed_database_service", + "db_healthcheck_token", "replication", "is_primary", "is_replication_setup", @@ -758,6 +762,35 @@ "fieldtype": "Check", "label": "Enable On-Prem Failover Support" }, + { + "default": "0", + "fieldname": "stop_incident_actions", + "fieldtype": "Check", + "label": "Stop Incident Actions" + }, + { + "fieldname": "db_healthcheck_token", + "fieldtype": "Password", + "label": "DB Healthcheck Token" + }, + { + "default": "30", + "depends_on": "eval:!doc.__islocal && !doc.public", + "description": "No. of days before product warranty setting can be changed again for sites in this server", + "fieldname": "site_warranty_change_cooldown", + "fieldtype": "Int", + "label": "Site Warranty change Cooldown", + "read_only_depends_on": "eval:!!doc.__islocal" + }, + { + "default": "5", + "depends_on": "eval:!doc.__islocal && !doc.public", + "description": "Max no. of sites with product warranty this server can have", + "fieldname": "supported_site_quota", + "fieldtype": "Int", + "label": "Supported Site Quota", + "read_only_depends_on": "eval:!!doc.__islocal" + }, { "default": "0", "fieldname": "is_agent_auth_setup", diff --git a/press/tests/test_agent.py b/press/tests/test_agent.py index 2e4bb8ac895..284f88cb17e 100644 --- a/press/tests/test_agent.py +++ b/press/tests/test_agent.py @@ -8,6 +8,7 @@ import jwt import requests import responses +from frappe.exceptions import ValidationError from frappe.tests.utils import FrappeTestCase from press.agent import Agent, AgentRequestSkippedException @@ -147,7 +148,7 @@ def test_get_secret_raises_if_secret_not_configured(self): settings.secret = "" settings.save(ignore_permissions=True) - self.assertRaises(ValueError, agent.get_secret) + self.assertRaises(ValidationError, agent.get_secret) def test_verify_request_token_success(self): server = create_test_server() From 5ce545a9c1dca0a6c7b781f61abf6d8cb9096de3 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Sun, 24 May 2026 18:24:30 +0000 Subject: [PATCH 54/59] chore(agent): Remove printing payload --- press/agent.py | 1 - .../press/doctype/agent_job/test_agent_job.py | 67 ------------------- 2 files changed, 68 deletions(-) diff --git a/press/agent.py b/press/agent.py index 255dfe76b24..2418d091b2f 100644 --- a/press/agent.py +++ b/press/agent.py @@ -977,7 +977,6 @@ def _verify_request_token(self, token: str): "require": ["exp", "server", "jti"], }, ) - print(payload) except jwt.ExpiredSignatureError as err: raise ValueError("Token expired") from err diff --git a/press/press/doctype/agent_job/test_agent_job.py b/press/press/doctype/agent_job/test_agent_job.py index 781d5f8bd17..ebf2619efdb 100644 --- a/press/press/doctype/agent_job/test_agent_job.py +++ b/press/press/doctype/agent_job/test_agent_job.py @@ -404,70 +404,3 @@ def test_handle_polled_job_does_not_update_if_same_status( mock_skip_pending_steps.assert_called_once_with(job.name) mock_process_job_updates.assert_called_once_with(job.name, polled_job) mock_publish_update.assert_called_once_with(job.name) - - @patch("frappe.publish_realtime") - def test_publish_update_publishes_realtime_events(self, mock_publish): - site = create_test_site() - - job = frappe.get_doc( - { - "doctype": "Agent Job", - "job_type": "New Site", - "server_type": "Server", - "server": site.server, - "site": site.name, - "status": "Success", - "job_id": 103, - "request_method": "POST", - "request_path": "test/path", - "request_data": "{}", - } - ).insert() - - step = frappe.get_doc( - { - "doctype": "Agent Job Step", - "agent_job": job.name, - "step_name": "Test Step", - "status": "Success", - "duration": "00:00:01", - } - ).insert() - - from press.press.doctype.agent_job.agent_job import publish_update - - publish_update(job.name) - - self.assertTrue(mock_publish.called) - - events = [call.kwargs.get("event") for call in mock_publish.call_args_list] - - self.assertIn("agent_job_update", events) - self.assertIn("doc_update", events) - self.assertIn("list_update", events) - - mock_publish.assert_any_call( - event="agent_job_update", - doctype="Agent Job", - docname=job.name, - message=Mock(), - ) - - mock_publish.assert_any_call( - event="doc_update", - doctype="Agent Job", - docname=job.name, - message={"doctype": "Agent Job", "name": job.name}, - ) - - mock_publish.assert_any_call( - event="list_update", - message={"doctype": "Agent Job", "name": job.name}, - ) - - mock_publish.assert_any_call( - event="doc_update", - doctype="Agent Job Step", - docname=step.name, - message={"doctype": "Agent Job Step", "name": step.name}, - ) From 2984ca19d86f5050e6465a8e76b00e78238c1568 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 25 May 2026 07:30:39 +0000 Subject: [PATCH 55/59] fix(cspell): Fix formatting --- .cspell.json | 1592 ++++++++--------- .../press_settings/press_settings.json | 29 +- 2 files changed, 824 insertions(+), 797 deletions(-) diff --git a/.cspell.json b/.cspell.json index 60f15e77e5a..4e1ffb37bd3 100644 --- a/.cspell.json +++ b/.cspell.json @@ -1,797 +1,797 @@ { - "version": "0.2", - "language": "en", - "allowCompoundWords": true, - "ignorePaths": [ - "dashboard/node_modules", - "**/assets", - "*.json", - "**.jinja2", - "**.j2", - "**.service", - "**.yml", - "test_**", - "**.conf", - "requirements.txt", - "dev-requirements.txt", - "press/utils/country_timezone.py", - ".secrets.baseline", - "**go.sum", - "libs/**" - ], - "words": [ - "2.4.6", - "Aaiun", - "Ababa", - "activites", - "Adak", - "adblockers", - "Addis", - "aditya", - "Adminstrator", - "aescts", - "afero", - "Agejt", - "aggs", - "Ajkr", - "Akbary", - "Akts", - "Åland", - "Anadyr", - "Andhra", - "ansari", - "Aqtau", - "Aqtobe", - "Araguaina", - "Arunachal", - "Asmera", - "asmfmt", - "asname", - "asrc", - "ATEXT", - "athul", - "Atikokan", - "Atka", - "atleast", - "atotto", - "Atyrau", - "auid", - "awalsh", - "awwzvf", - "aymanbagabas", - "backgound", - "Baja", - "Balamurali", - "Barthelemy", - "Barthélemy", - "Bator", - "behavior", - "behaviour", - "benbjohnson", - "BENTO", - "binlog", - "biosdevname", - "blkid", - "bofq", - "boto", - "Bouvet", - "bouy", - "buildx", - "Busingen", - "Billing", - "Cabo", - "CCONTENT", - "cellbuf", - "cellbug", - "CFWS", - "chdir", - "Chhattisgarh", - "Choibalsan", - "Chuuk", - "chsh", - "chzyer", - "cidata", - "cint", - "clamav", - "clas", - "cli", - "cloudimg", - "CMDLINE", - "CNAME", - "cnsistency", - "CODECOV", - "codespell", - "commitlint", - "Comod", - "COMPATBILITY", - "confs", - "Consolas", - "Containerised", - "coveragerc", - "cpath", - "cpcommerce", - "cpuid", - "cpus", - "creat", - "creds", - "Creston", - "Csvg", - "csvg", - "CTEXT", - "CTPBJ", - "Cuiaba", - "Cunha", - "cust", - "Dacca", - "Dadra", - "Danmarkshavn", - "Darkify", - "dateutil", - "davecgh", - "DAYOFMONTH", - "DAYOFWEEK", - "DAYOFYEAR", - "daum", - "dbgsym", - "dboptimize", - "dbserver", - "DBUS", - "dcbs", - "DCONTENT", - "ddeb", - "ddebs", - "ddl", - "dearmor", - "devscripts", - "devtmpfs", - "dffx", - "Dgzr", - "Dili", - "dmypy", - "DNOQOHHMYYI", - "dnsmasq", - "dnspython", - "dnsutils", - "doesnt", - "dont", - "DONTNEED", - "dpkg", - "dribbble", - "DSEes", - "DTEXT", - "duckdb", - "DUID", - "Dumont", - "EACCES", - "earlyoom", - "ecommerce", - "EDITMSG", - "Efate", - "efi", - "EHIKF", - "Eirunepe", - "elif", - "elts", - "emaill", - "emandate", - "Ensenada", - "EPERM", - "equivs", - "erikgeiser", - "erpdb", - "erpnext", - "erpnextcom", - "erpnextsmb", - "errgo", - "Eswatini", - "Eucla", - "euid", - "EVHT", - "execv", - "execve", - "exitst", - "Exlude", - "FADV", - "Fakaofo", - "faris", - "Faso", - "fchmod", - "fchmodat", - "fchown", - "fchownat", - "fcrestore", - "Fdvmq", - "FEFF", - "Fffphu", - "filippo", - "Fmbeo", - "Fpww", - "frappeclient", - "frappehr", - "Frappeio", - "frappeui", - "fremovexattr", - "fsetxattr", - "fstype", - "ftrace", - "ftruncate", - "Fung", - "FWUP", - "Fzqt", - "gcore", - "Gekx", - "gaierror", - "genproto", - "getdate", - "getitimer", - "gget", - "ghaction", - "ghead", - "githubusercontent", - "glfw", - "glog", - "gmxxxxcom", - "gnueabi", - "GOARCH", - "goasm", - "goccy", - "godebug", - "gofork", - "goidentity", - "gokrb", - "goleak", - "gonum", - "gopkg", - "gotool", - "Gozu", - "Gqttikk", - "grequests", - "gshadow", - "GSSAPI", - "gstin", - "gstinhide", - "gstinshow", - "gtid", - "gunicorn", - "gxzc", - "hakanensari", - "Haryana", - "hase", - "Haveli", - "hetzner", - "hdel", - "hdfs", - "hget", - "Himachal", - "honnef", - "hookpy", - "Hovd", - "hrms", - "hrtimers", - "hset", - "hsts", - "htpasswd", - "Hvyanc", - "ibdata", - "Ibhfb", - "ibtmp", - "iceber", - "ifaces", - "Ifalt", - "ifnames", - "ifnull", - "IGST", - "ikxn", - "ILIKE", - "imds", - "Incase", - "innodb", - "innoterra", - "inodes", - "inplace", - "interactjs", - "interner", - "Inuvik", - "invs", - "iour", - "iowait", - "ipaddress", - "ipdb", - "IPEYBICE", - "iputils", - "ipython", - "IRET", - "isatty", - "isin", - "isnotnull", - "istable", - "ITIMER", - "Jammu", - "jcmturner", - "jemalloc", - "Jharkhand", - "Jhuj", - "jmespath", - "JMWS", - "Jnsl", - "joomla", - "joxit", - "jscache", - "jsons", - "jstemmer", - "Jujuy", - "JZNG", - "Karnataka", - "kcontinue", - "kdhz", - "KGUJ", - "Khandyga", - "KHTML", - "Kiritimati", - "kisielk", - "Kitts", - "Kolkata", - "kontinue", - "Kralendijk", - "Kuala", - "Kvsc", - "kwarg", - "kwargs", - "Ladakh", - "Lakshadweep", - "Latrh", - "lchown", - "Leste", - "libc", - "libdevel", - "libharfbuzz", - "libpango", - "libpangocairo", - "libsm", - "libstdc", - "libx", - "libxcb", - "libxext", - "libxmuu", - "libxrender", - "Lindeman", - "llen", - "localds", - "logex", - "Longyearbyen", - "LOUAA", - "lpush", - "lqez", - "lrange", - "lremovexattr", - "lsetxattr", - "lucasb", - "Lumpur", - "luxon", - "Maarten", - "Madhya", - "MADKY", - "Mahe", - "makeprg", - "marcboeker", - "MARIADB", - "mariadbd", - "Marino", - "Marketpalce", - "mattn", - "Mayen", - "mbps", - "mccabe", - "Meghalaya", - "Menlo", - "mergify", - "Metlakatla", - "mhpd", - "Mhsc", - "Minh", - "missingok", - "Mizoram", - "mkdir", - "mkisofs", - "Mmckchk", - "mname", - "momentjs", - "Moresby", - "moto", - "Mpesa", - "msgprint", - "msisdn", - "Mtay", - "muieblackcat", - "Murdo", - "mxschmitt", - "myadmin", - "Mycp", - "myisam", - "mypma", - "mypy", - "mysqladmin", - "mysqld", - "mysqldb", - "Mywk", - "nach", - "Nadu", - "Nagar", - "ncdu", - "nedded", - "NEFT", - "Nera", - "netcfg", - "NFKH", - "NGROK", - "nineth", - "Nipigon", - "nistp", - "njsproj", - "nocompress", - "nofail", - "NOFORK", - "noozm", - "NOPASSWD", - "Noronha", - "Norte", - "notifempty", - "notin", - "nqhxc", - "ntfs", - "ntvs", - "Nuuk", - "nvme", - "Nxzjr", - "objx", - "Occurred", - "OCI", - "OCID", - "ocpu", - "ocpus", - "ocsp", - "Odisha", - "Ojinaga", - "Olgu", - "OLQY", - "ondismiss", - "onfail", - "oom", - "opasswd", - "OPENBLAS", - "opions", - "overriden", - "OWUVXXW", - "oxxk", - "Paasphrase", - "packagejsons", - "Pago", - "paise", - "Pangnirtung", - "paramiko", - "parentfield", - "parenttype", - "pborman", - "pckj", - "pckjs", - "Pedning", - "Pesa", - "pexpect", - "pfiles", - "pflag", - "Pfrw", - "pgrep", - "phpmyadmin", - "pids", - "Pjpw", - "pkgs", - "pmadb", - "pmezard", - "Pmirojx", - "Pohnpei", - "popperjs", - "pppconfig", - "pppoeconf", - "pprof", - "Pradesh", - "primarys", - "prm", - "probability", - "proces", - "procs", - "proot", - "promql", - "protoc", - "psync", - "ptype", - "Puducherry", - "Punta", - "Pushkarev", - "pycache", - "pycups", - "pyngrok", - "pypika", - "pypr", - "pypr", - "pyproject", - "pypt", - "pyspy", - "PYTHONUNBUFFERED", - "pytz", - "pyunit", - "Pziu", - "QCONTENT", - "Qostanay", - "Qrcode", - "qrcode", - "QTEXT", - "Qyzylorda", - "rcfile", - "rdata", - "rdatatype", - "recognise", - "rediffmail", - "redisearch", - "referer", - "Regs", - "Releas", - "removexattr", - "reqd", - "Rerunnability", - "rerunnable", - "Réunion", - "Rhiv", - "Rhxk", - "Rica", - "RIOHXQEHM", - "Rioja", - "rivo", - "rname", - "rnyq", - "rogpeppe", - "rootfs", - "rpush", - "rrset", - "Rsya", - "rtype", - "rutwikhdev", - "ruzy", - "rzgre", - "saas", - "sadd", - "sahilm", - "Santo", - "saurabh", - "sbool", - "Scoresbysund", - "sda", - "sdext", - "sdf", - "sdg", - "sdist", - "sdomain", - "secho", - "Segoe", - "segs", - "seperate", - "serializability", - "setxattr", - "shadrak", - "shuralyov", - "signup", - "sina", - "SLXVDP", - "slugified", - "smembers", - "SNUBA", - "snuba", - "socketio", - "softirq", - "somes", - "sonner", - "spamd", - "splited", - "sprintf", - "squashfs", - "Srednekolymsk", - "Starke", - "stdc", - "stime", - "stkpush", - "stopasgroup", - "Storge", - "stretchr", - "stripnl", - "Strnm", - "supectl", - "supervisorctl", - "supervisord", - "swapuuid", - "SYMBOLICATOR", - "symbolicator", - "synchronise", - "Syowa", - "Syrus", - "sysrq", - "tanmoy", - "tanmoysrt", - "tanxxxxxxkar", - "tcmalloc", - "Telangana", - "termenv", - "Thgcy", - "tidb", - "Tiraspol", - "Tkndys", - "tldextract", - "Tmate", - "tmpfs", - "Tokelau", - "tomli", - "Tongatapu", - "TOOD", - "TOTP", - "totp", - "tqdm", - "Troso", - "TSZK", - "tupple", - "Tvyn", - "Twillio", - "udiff", - "Udxsrq", - "uefi", - "Uenf", - "Ujung", - "Ulaanbaatar", - "Ulan", - "unarchived", - "Unbilled", - "uncollectible", - "unfollow", - "unindex", - "unindexed", - "unindexing", - "uniseg", - "unlinkat", - "unparse", - "unpatch", - "unplugin", - "Unprovisioned", - "unscrub", - "unsuspended", - "Unsuspending", - "unsuspension", - "updadted", - "urandom", - "uring", - "Urville", - "USEDNS", - "Ushuaia", - "Uttar", - "Uttarakhand", - "Uzhgorod", - "vagrant", - "varkw", - "vasile", - "VBDHE", - "vcpu", - "vcpus", - "vda", - "Velho", - "venv", - "Vetur", - "vetur", - "Vevay", - "vfat", - "vimrc", - "virsh", - "virtualenv", - "Vite", - "vite", - "vitess", - "VMI", - "vmis", - "vnic", - "Vodacom", - "volid", - "vpus", - "vtprotobuf", - "vueuse", - "vxeg", - "Vzzq", - "Wazuh", - "wazuh", - "weasyprint", - "webp", - "Werkzeug", - "Winamac", - "witht", - "Wiue", - "wkhtmlto", - "WKHTMLTOPDF", - "wkhtmltox", - "Wpym", - "xampp", - "xauth", - "xcall", - "xerrors", - "xfonts", - "XHOMZ", - "xitongsys", - "xlink", - "Xpai", - "XPUT", - "Xrwmb", - "xvda", - "xvdf", - "xvdg", - "Xwgt", - "xyproto", - "Xyrw", - "Xzmq", - "Yakutat", - "Yancowinna", - "Yekq", - "Ynel", - "yxei", - "Yzuve", - "zeebo", - "zloirock", - "Zpyihv", - "ZSTD", - "Zvkq", - "botocore", - "reka", - "pydo", - "erpnextv", - "hrmsv", - "unyank", - "unyanking", - "unyanked", - "shirou", - "gopsutil", - "SSIZE", - "innobase", - "FSEG", - "XDES", - "FLST", - "pyotp", - "noopener", - "noreferrer", - "nosemgrep", - "centralise", - "PSIIO", - "journalctl", - "ptrs", - "serialised", - "setnx", - "ssiyad", - "Aradhya", - "Tripathi", - "siduck", - "prathameshkurunkar", - "Bowrna", - "vitepress", - "resolvconf", - "lsync", - "lsyncd", - "awk", - "gawk", - "picklable", - "ndarray", - "mult", - "ruleid", - "chatwoot", - "ssti", - "tcmalloc", - "libncurses", - "libncursesw", - "DWITHOUT", - "wextra", - "SONAME", - "decommitted", - "libgnutls", - "UDF" - ] -} + "version": "0.2", + "language": "en", + "allowCompoundWords": true, + "ignorePaths": [ + "dashboard/node_modules", + "**/assets", + "*.json", + "**.jinja2", + "**.j2", + "**.service", + "**.yml", + "test_**", + "**.conf", + "requirements.txt", + "dev-requirements.txt", + "press/utils/country_timezone.py", + ".secrets.baseline", + "**go.sum", + "libs/**" + ], + "words": [ + "2.4.6", + "Aaiun", + "Ababa", + "activites", + "Adak", + "adblockers", + "Addis", + "aditya", + "Adminstrator", + "aescts", + "afero", + "Agejt", + "aggs", + "Ajkr", + "Akbary", + "Akts", + "Åland", + "Anadyr", + "Andhra", + "ansari", + "Aqtau", + "Aqtobe", + "Araguaina", + "Arunachal", + "Asmera", + "asmfmt", + "asname", + "asrc", + "ATEXT", + "athul", + "Atikokan", + "Atka", + "atleast", + "atotto", + "Atyrau", + "auid", + "awalsh", + "awwzvf", + "aymanbagabas", + "backgound", + "Baja", + "Balamurali", + "Barthelemy", + "Barthélemy", + "Bator", + "behavior", + "behaviour", + "benbjohnson", + "BENTO", + "binlog", + "biosdevname", + "blkid", + "bofq", + "boto", + "Bouvet", + "bouy", + "buildx", + "Busingen", + "Billing", + "Cabo", + "CCONTENT", + "cellbuf", + "cellbug", + "CFWS", + "chdir", + "Chhattisgarh", + "Choibalsan", + "Chuuk", + "chsh", + "chzyer", + "cidata", + "cint", + "clamav", + "clas", + "cli", + "cloudimg", + "CMDLINE", + "CNAME", + "cnsistency", + "CODECOV", + "codespell", + "commitlint", + "Comod", + "COMPATBILITY", + "confs", + "Consolas", + "Containerised", + "coveragerc", + "cpath", + "cpcommerce", + "cpuid", + "cpus", + "creat", + "creds", + "Creston", + "Csvg", + "csvg", + "CTEXT", + "CTPBJ", + "Cuiaba", + "Cunha", + "cust", + "Dacca", + "Dadra", + "Danmarkshavn", + "Darkify", + "dateutil", + "davecgh", + "DAYOFMONTH", + "DAYOFWEEK", + "DAYOFYEAR", + "daum", + "dbgsym", + "dboptimize", + "dbserver", + "DBUS", + "dcbs", + "DCONTENT", + "ddeb", + "ddebs", + "ddl", + "dearmor", + "devscripts", + "devtmpfs", + "dffx", + "Dgzr", + "Dili", + "dmypy", + "DNOQOHHMYYI", + "dnsmasq", + "dnspython", + "dnsutils", + "doesnt", + "dont", + "DONTNEED", + "dpkg", + "dribbble", + "DSEes", + "DTEXT", + "duckdb", + "DUID", + "Dumont", + "EACCES", + "earlyoom", + "ecommerce", + "EDITMSG", + "Efate", + "efi", + "EHIKF", + "Eirunepe", + "elif", + "elts", + "emaill", + "emandate", + "Ensenada", + "EPERM", + "equivs", + "erikgeiser", + "erpdb", + "erpnext", + "erpnextcom", + "erpnextsmb", + "errgo", + "Eswatini", + "Eucla", + "euid", + "EVHT", + "execv", + "execve", + "exitst", + "Exlude", + "FADV", + "Fakaofo", + "faris", + "Faso", + "fchmod", + "fchmodat", + "fchown", + "fchownat", + "fcrestore", + "Fdvmq", + "FEFF", + "Fffphu", + "filippo", + "Fmbeo", + "Fpww", + "frappeclient", + "frappehr", + "Frappeio", + "frappeui", + "fremovexattr", + "fsetxattr", + "fstype", + "ftrace", + "ftruncate", + "Fung", + "FWUP", + "Fzqt", + "gcore", + "Gekx", + "gaierror", + "genproto", + "getdate", + "getitimer", + "gget", + "ghaction", + "ghead", + "githubusercontent", + "glfw", + "glog", + "gmxxxxcom", + "gnueabi", + "GOARCH", + "goasm", + "goccy", + "godebug", + "gofork", + "goidentity", + "gokrb", + "goleak", + "gonum", + "gopkg", + "gotool", + "Gozu", + "Gqttikk", + "grequests", + "gshadow", + "GSSAPI", + "gstin", + "gstinhide", + "gstinshow", + "gtid", + "gunicorn", + "gxzc", + "hakanensari", + "Haryana", + "hase", + "Haveli", + "hetzner", + "hdel", + "hdfs", + "hget", + "Himachal", + "honnef", + "hookpy", + "Hovd", + "hrms", + "hrtimers", + "hset", + "hsts", + "htpasswd", + "Hvyanc", + "ibdata", + "Ibhfb", + "ibtmp", + "iceber", + "ifaces", + "Ifalt", + "ifnames", + "ifnull", + "IGST", + "ikxn", + "ILIKE", + "imds", + "Incase", + "innodb", + "innoterra", + "inodes", + "inplace", + "interactjs", + "interner", + "Inuvik", + "invs", + "iour", + "iowait", + "ipaddress", + "ipdb", + "IPEYBICE", + "iputils", + "ipython", + "IRET", + "isatty", + "isin", + "isnotnull", + "istable", + "ITIMER", + "Jammu", + "jcmturner", + "jemalloc", + "Jharkhand", + "Jhuj", + "jmespath", + "JMWS", + "Jnsl", + "joomla", + "joxit", + "jscache", + "jsons", + "jstemmer", + "Jujuy", + "JZNG", + "Karnataka", + "kcontinue", + "kdhz", + "KGUJ", + "Khandyga", + "KHTML", + "Kiritimati", + "kisielk", + "Kitts", + "Kolkata", + "kontinue", + "Kralendijk", + "Kuala", + "Kvsc", + "kwarg", + "kwargs", + "Ladakh", + "Lakshadweep", + "Latrh", + "lchown", + "Leste", + "libc", + "libdevel", + "libharfbuzz", + "libpango", + "libpangocairo", + "libsm", + "libstdc", + "libx", + "libxcb", + "libxext", + "libxmuu", + "libxrender", + "Lindeman", + "llen", + "localds", + "logex", + "Longyearbyen", + "LOUAA", + "lpush", + "lqez", + "lrange", + "lremovexattr", + "lsetxattr", + "lucasb", + "Lumpur", + "luxon", + "Maarten", + "Madhya", + "MADKY", + "Mahe", + "makeprg", + "marcboeker", + "MARIADB", + "mariadbd", + "Marino", + "Marketpalce", + "mattn", + "Mayen", + "mbps", + "mccabe", + "Meghalaya", + "Menlo", + "mergify", + "Metlakatla", + "mhpd", + "Mhsc", + "Minh", + "missingok", + "Mizoram", + "mkdir", + "mkisofs", + "Mmckchk", + "mname", + "momentjs", + "Moresby", + "moto", + "Mpesa", + "msgprint", + "msisdn", + "Mtay", + "muieblackcat", + "Murdo", + "mxschmitt", + "myadmin", + "Mycp", + "myisam", + "mypma", + "mypy", + "mysqladmin", + "mysqld", + "mysqldb", + "Mywk", + "nach", + "Nadu", + "Nagar", + "ncdu", + "nedded", + "NEFT", + "Nera", + "netcfg", + "NFKH", + "NGROK", + "nineth", + "Nipigon", + "nistp", + "njsproj", + "nocompress", + "nofail", + "NOFORK", + "noozm", + "NOPASSWD", + "Noronha", + "Norte", + "notifempty", + "notin", + "nqhxc", + "ntfs", + "ntvs", + "Nuuk", + "nvme", + "Nxzjr", + "objx", + "Occurred", + "OCI", + "OCID", + "ocpu", + "ocpus", + "ocsp", + "Odisha", + "Ojinaga", + "Olgu", + "OLQY", + "ondismiss", + "onfail", + "oom", + "opasswd", + "OPENBLAS", + "opions", + "overriden", + "OWUVXXW", + "oxxk", + "Paasphrase", + "packagejsons", + "Pago", + "paise", + "Pangnirtung", + "paramiko", + "parentfield", + "parenttype", + "pborman", + "pckj", + "pckjs", + "Pedning", + "Pesa", + "pexpect", + "pfiles", + "pflag", + "Pfrw", + "pgrep", + "phpmyadmin", + "pids", + "Pjpw", + "pkgs", + "pmadb", + "pmezard", + "Pmirojx", + "Pohnpei", + "popperjs", + "pppconfig", + "pppoeconf", + "pprof", + "Pradesh", + "primarys", + "prm", + "probability", + "proces", + "procs", + "proot", + "promql", + "protoc", + "psync", + "ptype", + "Puducherry", + "Punta", + "Pushkarev", + "pycache", + "pycups", + "pyngrok", + "pypika", + "pypr", + "pypr", + "pyproject", + "pypt", + "pyspy", + "PYTHONUNBUFFERED", + "pytz", + "pyunit", + "Pziu", + "QCONTENT", + "Qostanay", + "Qrcode", + "qrcode", + "QTEXT", + "Qyzylorda", + "rcfile", + "rdata", + "rdatatype", + "recognise", + "rediffmail", + "redisearch", + "referer", + "Regs", + "Releas", + "removexattr", + "reqd", + "Rerunnability", + "rerunnable", + "Réunion", + "Rhiv", + "Rhxk", + "Rica", + "RIOHXQEHM", + "Rioja", + "rivo", + "rname", + "rnyq", + "rogpeppe", + "rootfs", + "rpush", + "rrset", + "Rsya", + "rtype", + "rutwikhdev", + "ruzy", + "rzgre", + "saas", + "sadd", + "sahilm", + "Santo", + "saurabh", + "sbool", + "Scoresbysund", + "sda", + "sdext", + "sdf", + "sdg", + "sdist", + "sdomain", + "secho", + "Segoe", + "segs", + "seperate", + "serializability", + "setxattr", + "shadrak", + "shuralyov", + "signup", + "sina", + "SLXVDP", + "slugified", + "smembers", + "SNUBA", + "snuba", + "socketio", + "softirq", + "somes", + "sonner", + "spamd", + "splited", + "sprintf", + "squashfs", + "Srednekolymsk", + "Starke", + "stdc", + "stime", + "stkpush", + "stopasgroup", + "Storge", + "stretchr", + "stripnl", + "Strnm", + "supectl", + "supervisorctl", + "supervisord", + "swapuuid", + "SYMBOLICATOR", + "symbolicator", + "synchronise", + "Syowa", + "Syrus", + "sysrq", + "tanmoy", + "tanmoysrt", + "tanxxxxxxkar", + "tcmalloc", + "Telangana", + "termenv", + "Thgcy", + "tidb", + "Tiraspol", + "Tkndys", + "tldextract", + "Tmate", + "tmpfs", + "Tokelau", + "tomli", + "Tongatapu", + "TOOD", + "TOTP", + "totp", + "tqdm", + "Troso", + "TSZK", + "tupple", + "Tvyn", + "Twillio", + "udiff", + "Udxsrq", + "uefi", + "Uenf", + "Ujung", + "Ulaanbaatar", + "Ulan", + "unarchived", + "Unbilled", + "uncollectible", + "unfollow", + "unindex", + "unindexed", + "unindexing", + "uniseg", + "unlinkat", + "unparse", + "unpatch", + "unplugin", + "Unprovisioned", + "unscrub", + "unsuspended", + "Unsuspending", + "unsuspension", + "updadted", + "urandom", + "uring", + "Urville", + "USEDNS", + "Ushuaia", + "Uttar", + "Uttarakhand", + "Uzhgorod", + "vagrant", + "varkw", + "vasile", + "VBDHE", + "vcpu", + "vcpus", + "vda", + "Velho", + "venv", + "Vetur", + "vetur", + "Vevay", + "vfat", + "vimrc", + "virsh", + "virtualenv", + "Vite", + "vite", + "vitess", + "VMI", + "vmis", + "vnic", + "Vodacom", + "volid", + "vpus", + "vtprotobuf", + "vueuse", + "vxeg", + "Vzzq", + "Wazuh", + "wazuh", + "weasyprint", + "webp", + "Werkzeug", + "Winamac", + "witht", + "Wiue", + "wkhtmlto", + "WKHTMLTOPDF", + "wkhtmltox", + "Wpym", + "xampp", + "xauth", + "xcall", + "xerrors", + "xfonts", + "XHOMZ", + "xitongsys", + "xlink", + "Xpai", + "XPUT", + "Xrwmb", + "xvda", + "xvdf", + "xvdg", + "Xwgt", + "xyproto", + "Xyrw", + "Xzmq", + "Yakutat", + "Yancowinna", + "Yekq", + "Ynel", + "yxei", + "Yzuve", + "zeebo", + "zloirock", + "Zpyihv", + "ZSTD", + "Zvkq", + "botocore", + "reka", + "pydo", + "erpnextv", + "hrmsv", + "unyank", + "unyanking", + "unyanked", + "shirou", + "gopsutil", + "SSIZE", + "innobase", + "FSEG", + "XDES", + "FLST", + "pyotp", + "noopener", + "noreferrer", + "nosemgrep", + "centralise", + "PSIIO", + "journalctl", + "ptrs", + "serialised", + "setnx", + "ssiyad", + "Aradhya", + "Tripathi", + "siduck", + "prathameshkurunkar", + "Bowrna", + "vitepress", + "resolvconf", + "lsync", + "lsyncd", + "awk", + "gawk", + "picklable", + "ndarray", + "mult", + "ruleid", + "chatwoot", + "ssti", + "tcmalloc", + "libncurses", + "libncursesw", + "DWITHOUT", + "wextra", + "SONAME", + "decommitted", + "libgnutls", + "UDF" + ] +} \ No newline at end of file diff --git a/press/press/doctype/press_settings/press_settings.json b/press/press/doctype/press_settings/press_settings.json index 2b0d8be5c6a..9b6a2507d58 100644 --- a/press/press/doctype/press_settings/press_settings.json +++ b/press/press/doctype/press_settings/press_settings.json @@ -20,6 +20,10 @@ "column_break_wrqp", "usage_record_creation_batch_size", "default_server_plan_type", + "plans_section", + "default_dedicated_server_site_warranty_change_cooldown", + "column_break_kujg", + "default_dedicated_server_site_warranty_quota", "invoicing_section", "invoicing_column", "gst_percentage", @@ -1662,6 +1666,29 @@ "fieldtype": "Check", "label": "Use New Deploy Flow" }, + { + "fieldname": "plans_section", + "fieldtype": "Section Break", + "label": "Plans" + }, + { + "fieldname": "column_break_kujg", + "fieldtype": "Column Break" + }, + { + "default": "30", + "description": "Default number of days before the site warranty setting can be changed for newly created dedicated servers", + "fieldname": "default_dedicated_server_site_warranty_change_cooldown", + "fieldtype": "Int", + "label": "Default Dedicated Server Site Warranty change Cooldown" + }, + { + "default": "5", + "description": "Default max number of sites with product warranty for newly created dedicated servers\n", + "fieldname": "default_dedicated_server_site_warranty_quota", + "fieldtype": "Int", + "label": "Default Dedicated Server Supported Site Quota" + }, { "fieldname": "chatwoot_section", "fieldtype": "Section Break", @@ -1720,7 +1747,7 @@ ], "issingle": 1, "links": [], - "modified": "2026-05-24 19:40:21.224827", + "modified": "2026-04-30 18:44:43.851468", "modified_by": "Administrator", "module": "Press", "name": "Press Settings", From d8f26600ecb49e40d8beb6531009b6eecee5f971 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 25 May 2026 08:36:49 +0000 Subject: [PATCH 56/59] fix(database-server): Fix database server fields --- .../database_server/database_server.json | 41 ++++++++++++++++--- .../press/doctype/nat_server/nat_server.json | 1 + 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/press/press/doctype/database_server/database_server.json b/press/press/doctype/database_server/database_server.json index 05ef1526222..4ae12c087dc 100644 --- a/press/press/doctype/database_server/database_server.json +++ b/press/press/doctype/database_server/database_server.json @@ -81,6 +81,7 @@ "column_break_eiyu", "memory_allocator", "memory_allocator_version", + "tcmalloc_release_rate", "section_break_ladc", "is_performance_schema_enabled", "mariadb_system_variables", @@ -120,7 +121,13 @@ "binlog_index_retention_days", "column_break_blnf", "enable_binlog_upload_to_s3", - "uploaded_binlogs_retention_days" + "uploaded_binlogs_retention_days", + "monitor_tab", + "is_mariadb_monitor_installed", + "section_break_uzkb", + "is_external_healthcheck_enabled", + "column_break_umtu", + "is_auto_coredump_enabled" ], "fields": [ { @@ -539,8 +546,12 @@ "read_only": 1 }, { - "fieldname": "section_break_ladc", - "fieldtype": "Section Break" + "default": "1", + "depends_on": "eval: doc.memory_allocator == \"TCMalloc\"", + "fieldname": "tcmalloc_release_rate", + "fieldtype": "Int", + "label": "TCMalloc Release Rate", + "read_only": 1 }, { "fieldname": "section_break_ladc", @@ -795,6 +806,18 @@ "label": "NAT Server", "options": "NAT Server" }, + { + "fieldname": "monitor_tab", + "fieldtype": "Tab Break", + "label": "Monitor" + }, + { + "default": "0", + "fieldname": "is_mariadb_monitor_installed", + "fieldtype": "Check", + "label": "Is MariaDB Monitor Installed", + "read_only": 1 + }, { "default": "0", "fieldname": "is_agent_auth_setup", @@ -802,15 +825,21 @@ "label": "Is Agent Auth Setup", "read_only": 1 }, + { + "default": "0", + "fieldname": "agent_job_update_feature", + "fieldtype": "Check", + "label": "Agent Job Update Feature" + }, { "fieldname": "section_break_uzkb", "fieldtype": "Section Break" }, { "default": "0", - "fieldname": "agent_job_update_feature", + "fieldname": "is_external_healthcheck_enabled", "fieldtype": "Check", - "label": "Agent Job Update Feature" + "label": "Is External Healthcheck Enabled" }, { "fieldname": "column_break_umtu", @@ -844,7 +873,7 @@ "link_fieldname": "database_server" } ], - "modified": "2026-05-24 18:48:47.951310", + "modified": "2026-05-19 03:59:16.146497", "modified_by": "Administrator", "module": "Press", "name": "Database Server", diff --git a/press/press/doctype/nat_server/nat_server.json b/press/press/doctype/nat_server/nat_server.json index 0cc72bf4c2b..22da7396351 100644 --- a/press/press/doctype/nat_server/nat_server.json +++ b/press/press/doctype/nat_server/nat_server.json @@ -179,6 +179,7 @@ } ], "grid_page_length": 50, + "rows_threshold_for_grid_search": 20, "index_web_pages_for_search": 1, "links": [ { From b290e6a38bfcd97e22f3b7a165cffbf9bd1f8d1c Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 25 May 2026 09:41:29 +0000 Subject: [PATCH 57/59] fix(server): Add missing fields --- .../database_server/database_server.py | 4 ++ .../doctype/press_settings/press_settings.py | 2 + press/press/doctype/server/server.py | 51 +++++++++++++------ 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/press/press/doctype/database_server/database_server.py b/press/press/doctype/database_server/database_server.py index e69dac069c3..74442d0005e 100644 --- a/press/press/doctype/database_server/database_server.py +++ b/press/press/doctype/database_server/database_server.py @@ -79,6 +79,9 @@ class DatabaseServer(BaseServer): hostname_abbreviation: DF.Data | None ip: DF.Data | None is_agent_auth_setup: DF.Check + is_auto_coredump_enabled: DF.Check + is_external_healthcheck_enabled: DF.Check + is_mariadb_monitor_installed: DF.Check is_binlog_indexer_running: DF.Check is_for_recovery: DF.Check is_monitoring_disabled: DF.Check @@ -128,6 +131,7 @@ class DatabaseServer(BaseServer): stalk_variable: DF.Data | None status: DF.Literal["Pending", "Installing", "Active", "Broken", "Archived"] tags: DF.Table[ResourceTag] + tcmalloc_release_rate: DF.Int team: DF.Link | None title: DF.Data | None tls_certificate_renewal_failed: DF.Check diff --git a/press/press/doctype/press_settings/press_settings.py b/press/press/doctype/press_settings/press_settings.py index 92230f8e00d..a2ce10d2b9c 100644 --- a/press/press/doctype/press_settings/press_settings.py +++ b/press/press/doctype/press_settings/press_settings.py @@ -114,6 +114,8 @@ class PressSettings(Document): cool_off_period: DF.Int data_40: DF.Data | None default_apps: DF.Table[AppGroup] + default_dedicated_server_site_warranty_change_cooldown: DF.Int + default_dedicated_server_site_warranty_quota: DF.Int default_outgoing_id: DF.Data | None default_outgoing_pass: DF.Data | None default_server_plan_type: DF.Link | None diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index 67857fa1e10..5afdee003db 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -2873,6 +2873,7 @@ class Server(BaseServer): cluster: DF.Link | None communication_infos: DF.Table[CommunicationInfo] database_server: DF.Link | None + db_healthcheck_token: DF.Password | None disable_agent_job_auto_retry: DF.Check domain: DF.Link | None enable_logical_replication_during_site_update: DF.Check @@ -2928,12 +2929,15 @@ class Server(BaseServer): self_hosted_mariadb_server: DF.Data | None self_hosted_server_domain: DF.Data | None set_bench_memory_limits: DF.Check + site_warranty_change_cooldown: DF.Int skip_scheduled_backups: DF.Check ssh_port: DF.Int ssh_user: DF.Data | None staging: DF.Check status: DF.Literal["Pending", "Installing", "Active", "Broken", "Archived"] stop_deployments: DF.Check + stop_incident_actions: DF.Check + supported_site_quota: DF.Int tags: DF.Table[ResourceTag] team: DF.Link | None title: DF.Data | None @@ -2967,14 +2971,13 @@ def validate_managed_database_service(self): self.managed_database_service = "" def on_update(self): - # If Database Server is changed for the server then change it for all the benches - if self.has_value_changed("database_server") or self.has_value_changed("managed_database_service"): - benches = frappe.get_all("Bench", {"server": self.name, "status": ("!=", "Archived")}) - for bench in benches: - bench = frappe.get_doc("Bench", bench) - bench.database_server = self.database_server - bench.managed_database_service = self.managed_database_service - bench.save() + self.update_benches() + + if self.database_server: + database_server_public = frappe.db.get_value("Database Server", self.database_server, "public") + + if database_server_public != self.public: + frappe.db.set_value("Database Server", self.database_server, "public", self.public) if self.has_value_changed("team"): self.update_subscription() @@ -2987,22 +2990,38 @@ def on_update(self): if self.public: self.auto_add_storage_min = max(self.auto_add_storage_min, PUBLIC_SERVER_AUTO_ADD_STORAGE_MIN) + self.validate_logical_replication() + + if self.is_new() and is_dedicated_server(self.name): + self.set_dedicated_server_site_warranty_quota_and_cooldown() + + if self.has_value_changed("agent_job_update_feature"): + self.update_feature(self.agent_job_update_feature) + + def update_benches(self): + if not ( + self.has_value_changed("database_server") or self.has_value_changed("managed_database_service") + ): + return + + benches = frappe.get_all("Bench", {"server": self.name, "status": ("!=", "Archived")}) + + for bench in benches: + bench = frappe.get_doc("Bench", bench.name) + bench.database_server = self.database_server + bench.managed_database_service = self.managed_database_service + bench.save() + + def validate_logical_replication(self): if ( self.has_value_changed("enable_logical_replication_during_site_update") and self.enable_logical_replication_during_site_update and frappe.db.count("Site", {"server": self.name, "status": ("!=", "Archived")}) > 1 ): - # Throw error if multiple sites are present on the server frappe.throw( - "Cannot enable logical replication during site update if multiple sites are present on the server. Please drop the sites in order to enable logical replication." + "Cannot enable logical replication during site update if multiple sites are present on the server." ) - if self.is_new() and is_dedicated_server(self.name): - self.set_dedicated_server_site_warranty_quota_and_cooldown() - - if self.has_value_changed("agent_job_update_feature"): - self.update_feature(self.agent_job_update_feature) - def update_db_server(self): if not self.database_server: return From 448217334ac70165922370d9aa0f16b81f46fc06 Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 25 May 2026 09:59:12 +0000 Subject: [PATCH 58/59] fix(site): Fix ruff issues --- press/api/site.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/press/api/site.py b/press/api/site.py index 9716a1b18a9..96a87b024d5 100644 --- a/press/api/site.py +++ b/press/api/site.py @@ -342,25 +342,6 @@ def validate_plan(server: str, site: str, new_plan: str, is_new: bool = False) - ["support_included", "dedicated_server_plan"], ) or (False, False) - -@validate_argument_types -def validate_plan( - server: str, - site: str, - new_plan: str, - is_new: bool = False, -) -> None: - if not frappe.db.exists("Site Plan", new_plan): - frappe.throw( - f"Plan {new_plan} does not exist", - frappe.DoesNotExistError, - ) # nosemgrep - - ( - is_current_plan_supported, - is_current_dedicated_server_plan, - ) = _get_current_plan_details(site, is_new) - new_site_plan = frappe.db.get_value( "Site Plan", new_plan, From 0a9e34e694922214753d7b93bfe2c52f970ec61f Mon Sep 17 00:00:00 2001 From: 20vikash Date: Mon, 25 May 2026 10:13:38 +0000 Subject: [PATCH 59/59] fix(server): Fix semgrep rules --- press/press/doctype/server/server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index 5afdee003db..7fbd30b9750 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -3018,8 +3018,9 @@ def validate_logical_replication(self): and self.enable_logical_replication_during_site_update and frappe.db.count("Site", {"server": self.name, "status": ("!=", "Archived")}) > 1 ): + # Throw error if multiple sites are present on the server frappe.throw( - "Cannot enable logical replication during site update if multiple sites are present on the server." + "Cannot enable logical replication during site update if multiple sites are present on the server. Please drop the sites in order to enable logical replication." ) def update_db_server(self):