diff --git a/.cspell.json b/.cspell.json index ed3cd3e50c4..4e1ffb37bd3 100644 --- a/.cspell.json +++ b/.cspell.json @@ -794,4 +794,4 @@ "libgnutls", "UDF" ] -} +} \ No newline at end of file diff --git a/press/agent.py b/press/agent.py index a0b05ea2d49..2418d091b2f 100644 --- a/press/agent.py +++ b/press/agent.py @@ -12,6 +12,7 @@ import frappe import frappe.utils +import jwt import requests from frappe.utils.password import get_decrypted_password from requests.exceptions import HTTPError @@ -949,6 +950,54 @@ 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_secret(self): + key = "agent_auth_secret" + + secret = frappe.cache().get_value(key) + if not secret: + press_settings = frappe.get_single("Press Settings") + secret = press_settings.get_password("secret") + + if not secret: + raise ValueError("Agent auth secret not configured") + + frappe.cache().set_value(key, secret) + + return secret + + def _verify_request_token(self, token: str): + secret = self.get_secret() + + try: + payload = jwt.decode( + token, + secret, + algorithms=["HS256"], + options={ + "require": ["exp", "server", "jti"], + }, + ) + + except jwt.ExpiredSignatureError as err: + raise ValueError("Token expired") from err + + except jwt.InvalidTokenError as err: + raise ValueError("Invalid token") from err + + if payload["server"] != self.server: + raise ValueError("Invalid server") + + return True + + def extract_and_verify_token(self, token): + if not token: + frappe.throw("Unsigned request from agent", frappe.PermissionError) + + 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() response = json_response = None @@ -956,6 +1005,7 @@ 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() + if raises and response.status_code >= 400: output = "\n\n".join([json_response.get("output", ""), json_response.get("traceback", "")]) if output == "\n\n": @@ -1026,6 +1076,7 @@ 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() + if raises: response.raise_for_status() return json_response @@ -1265,6 +1316,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 new file mode 100644 index 00000000000..0d0905d42f5 --- /dev/null +++ b/press/api/agent_auth.py @@ -0,0 +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(): + 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 1ae4641092f..2f8fdf9a7ed 100644 --- a/press/api/callbacks.py +++ b/press/api/callbacks.py @@ -3,12 +3,15 @@ from __future__ import annotations import ipaddress +import json +from typing import Any import frappe from frappe.rate_limiter import rate_limit from press.agent import Agent -from press.press.doctype.agent_job.agent_job import handle_polled_job +from press.api.agent_auth import verify_agent +from press.press.doctype.agent_job.agent_job import handle_polled_job, retry_undelivered_jobs from press.utils import log_error @@ -113,8 +116,57 @@ def callback(job_id: str | None = None): if not server: frappe.throw("Not permitted", frappe.ValidationError) + verify_agent() + job = verify_job_id(server, job_id) if not job: 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=500, seconds=60) +def update_job(job: str) -> None: + if not job: + return + + server, _ = verify_agent() + + parsed_job: dict[str, Any] = json.loads(job) + + job_doc = frappe.get_value( + "Agent Job", + fieldname=[ + "name", + "job_id", + "status", + "callback_failure_count", + "job_type", + ], + filters={"job_id": parsed_job["id"], "server": server}, + as_dict=True, + ) + + if not job_doc: + return + + 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/server.py b/press/api/server.py index 5d8d8fc02e4..5e486d2bec9 100644 --- a/press/api/server.py +++ b/press/api/server.py @@ -14,6 +14,7 @@ from frappe.utils.password import get_decrypted_password 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 @@ -987,20 +988,16 @@ def rename(name, title): @frappe.whitelist(allow_guest=True) -def benches_are_idle(server: str, access_token: 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: 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 + 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 new file mode 100644 index 00000000000..a57f9c968b8 --- /dev/null +++ b/press/api/tests/test_agent_auth.py @@ -0,0 +1,47 @@ +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.local.request = frappe._dict({"headers": {}}) + + self.assertRaises( + frappe.PermissionError, + verify_agent, + ) + + @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, + 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"}}) + + 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") + + 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..838d303bc20 --- /dev/null +++ b/press/api/tests/test_callbacks.py @@ -0,0 +1,60 @@ +import json +from unittest import TestCase +from unittest.mock import patch + +import frappe + +from press.api.callbacks import ( + retry_undelivered, + update_job, +) + + +class TestCallbacks(TestCase): + def tearDown(self): + frappe.db.rollback() + + 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.get_value") + def test_update_job_calls_handle_polled_job( + self, + mock_get_value, + mock_verify, + mock_handle_polled_job, + ): + mock_verify.return_value = ( + "test-server", + "Server", + ) + + mock_get_value.return_value = { + "name": "job-1", + "job_id": "123", + "status": "Running", + "callback_failure_count": 0, + "job_type": "Deploy", + } + + update_job(job=json.dumps({"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_retry_jobs.assert_called_once() 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 new file mode 100644 index 00000000000..c4b319a3e50 --- /dev/null +++ b/press/playbooks/roles/setup_agent_auth/tasks/main.yml @@ -0,0 +1,9 @@ +--- +- 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/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/agent_job/agent_job.py b/press/press/doctype/agent_job/agent_job.py index 5d25cdd5427..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 @@ -423,8 +426,15 @@ 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} + ) + # 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}) @@ -445,9 +455,21 @@ 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, 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) @@ -506,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 @@ -532,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", @@ -793,44 +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 + + if use_exponential_backoff and not should_retry_job(job, nowtime): + return + + if job.retry_count <= max_retry_count: + retry_job(job_name, job) + return + + mark_job_delivery_failure(job_name) + + +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 fdadcb8ed32..ebf2619efdb 100644 --- a/press/press/doctype/agent_job/test_agent_job.py +++ b/press/press/doctype/agent_job/test_agent_job.py @@ -295,3 +295,112 @@ 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) diff --git a/press/press/doctype/analytics_server/analytics_server.js b/press/press/doctype/analytics_server/analytics_server.js index c8bc3afa7ed..dc291e4711f 100644 --- a/press/press/doctype/analytics_server/analytics_server.js +++ b/press/press/doctype/analytics_server/analytics_server.js @@ -3,13 +3,23 @@ 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], [__('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], + [ + __( + frm.doc.is_agent_auth_setup + ? 'Regenerate Agent Token' + : 'Setup Agent Auth', + ), + 'setup_agent_auth', + false, + frm.doc.is_server_setup, + ], [ __('Fetch Keys'), 'fetch_keys', @@ -34,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 3ed13f523c4..467650e10f3 100644 --- a/press/press/doctype/database_server/database_server.js +++ b/press/press/doctype/database_server/database_server.js @@ -6,9 +6,9 @@ frappe.ui.form.on('Database Server', { frm.add_web_link( `/dashboard/servers/${frm.doc.name}`, __('Visit Dashboard'), - ); + ) - [ + ;[ [__('Ping Agent'), 'ping_agent', false, frm.doc.is_server_setup], [__('Ping Ansible'), 'ping_ansible', true, frm.doc.is_server_prepared], [ @@ -24,6 +24,16 @@ frappe.ui.form.on('Database Server', { true, frm.doc.is_server_setup, ], + [ + __( + frm.doc.is_agent_auth_setup + ? 'Regenerate Agent Token' + : 'Setup Agent Auth', + ), + 'setup_agent_auth', + false, + frm.doc.is_server_setup, + ], [ __('Install Filebeat'), 'install_filebeat', @@ -247,26 +257,26 @@ frappe.ui.form.on('Database 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( __('Increase Swap'), @@ -282,18 +292,18 @@ frappe.ui.form.on('Database Server', { default: 4, }, ], - }); + }) dialog.set_primary_action(__('Increase Swap'), (args) => { frm.call('increase_swap', args).then(() => { - dialog.hide(); - frm.refresh(); - }); - }); - dialog.show(); + dialog.hide() + frm.refresh() + }) + }) + dialog.show() }, __('Actions'), - ); + ) frm.add_custom_button( __('Perform Physical Backup'), () => { @@ -309,18 +319,18 @@ frappe.ui.form.on('Database Server', { reqd: 1, }, ], - }); + }) dialog.set_primary_action(__('Backup'), (args) => { frm.call('perform_physical_backup', args).then(() => { - dialog.hide(); - frm.refresh(); - }); - }); - dialog.show(); + dialog.hide() + frm.refresh() + }) + }) + dialog.show() }, __('Actions'), - ); + ) frm.add_custom_button( __('Update Memory Allocator Settings'), () => { @@ -345,7 +355,7 @@ frappe.ui.form.on('Database Server', { fieldname: 'tcmalloc_release_rate', }, ], - }); + }) dialog.set_primary_action(__('Update'), (args) => { frm.call({ @@ -354,15 +364,15 @@ frappe.ui.form.on('Database Server', { args: args, freeze: true, callback: () => { - dialog.hide(); - frm.refresh(); + dialog.hide() + frm.refresh() }, - }); - }); - dialog.show(); + }) + }) + dialog.show() }, __('Dangerous Actions'), - ); + ) frm.add_custom_button( __('Purge Binlogs'), @@ -377,7 +387,7 @@ frappe.ui.form.on('Database Server', { reqd: 1, }, ], - }); + }) dialog.set_primary_action(__('Purge'), (args) => { frm.call({ @@ -386,19 +396,19 @@ frappe.ui.form.on('Database Server', { args: args, freeze: true, callback: () => { - dialog.hide(); - frm.refresh(); + dialog.hide() + frm.refresh() }, - }); - }); - dialog.show(); + }) + }) + dialog.show() }, __('Dangerous Actions'), - ); + ) } }, hostname: function (frm) { - press.set_hostname_abbreviation(frm); + press.set_hostname_abbreviation(frm) }, -}); +}) diff --git a/press/press/doctype/database_server/database_server.json b/press/press/doctype/database_server/database_server.json index aba6a5bcbc0..4ae12c087dc 100644 --- a/press/press/doctype/database_server/database_server.json +++ b/press/press/doctype/database_server/database_server.json @@ -48,6 +48,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", @@ -816,6 +818,19 @@ "label": "Is MariaDB Monitor Installed", "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" + }, { "fieldname": "section_break_uzkb", "fieldtype": "Section Break" diff --git a/press/press/doctype/database_server/database_server.py b/press/press/doctype/database_server/database_server.py index 267a9245b07..e995d1eb4eb 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,12 @@ class DatabaseServer(BaseServer): hostname: DF.Data hostname_abbreviation: DF.Data | None ip: DF.Data | None + is_agent_auth_setup: DF.Check is_auto_coredump_enabled: 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_binlog_indexer_running: DF.Check + is_for_recovery: DF.Check is_monitoring_disabled: DF.Check is_performance_schema_enabled: DF.Check is_primary: DF.Check @@ -255,6 +257,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( @@ -821,6 +826,9 @@ def validate_server_id(self): def _setup_server(self): config = self._get_config() + secret = self._generate_secret() + agent_token = self.sign_agent_token(secret) + try: ansible = Ansible( playbook="self_hosted_db.yml" if getattr(self, "is_self_hosted", False) else "database.yml", @@ -837,6 +845,7 @@ def _setup_server(self): "monitoring_password": config.monitoring_password, "log_server": config.log_server, "kibana_password": config.kibana_password, + "agent_token": agent_token, "private_ip": self.private_ip, "server_id": self.server_id, "allocator": self.memory_allocator.lower(), @@ -856,6 +865,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 @@ -2694,6 +2704,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( @@ -2730,6 +2743,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/press/doctype/database_server/test_database_server.py b/press/press/doctype/database_server/test_database_server.py index 08c48ff68b2..ca005844942 100644 --- a/press/press/doctype/database_server/test_database_server.py +++ b/press/press/doctype/database_server/test_database_server.py @@ -150,3 +150,120 @@ 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_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") + + 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/log_server/log_server.js b/press/press/doctype/log_server/log_server.js index 774ad92e8ca..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], @@ -24,6 +24,16 @@ frappe.ui.form.on('Log Server', { false, frm.doc.is_server_setup, ], + [ + __( + frm.doc.is_agent_auth_setup + ? 'Regenerate Agent Token' + : '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) { @@ -36,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 011288075d4..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], @@ -30,6 +30,16 @@ frappe.ui.form.on('Monitor Server', { false, frm.doc.is_server_setup, ], + [ + __( + frm.doc.is_agent_auth_setup + ? 'Regenerate Agent Token' + : '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) { @@ -42,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 221910ff41c..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], @@ -16,6 +16,16 @@ frappe.ui.form.on('NAT Server', { true, frm.doc.is_server_setup, ], // added temporarily for setting up nginx & monitoring config + [ + __( + frm.doc.is_agent_auth_setup + ? 'Regenerate Agent Token' + : '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( @@ -27,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( @@ -69,7 +79,7 @@ frappe.ui.form.on('NAT Server', { cluster: frm.doc.cluster, secondary_private_ip: ['is', 'not set'], }, - }; + } }, }, ], @@ -85,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..22da7396351 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,9 +163,23 @@ "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, + "rows_threshold_for_grid_search": 20, "index_web_pages_for_search": 1, "links": [ { @@ -159,7 +188,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 +208,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/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/press_settings/press_settings.json b/press/press/doctype/press_settings/press_settings.json index 561807c3fa4..9b6a2507d58 100644 --- a/press/press/doctype/press_settings/press_settings.json +++ b/press/press/doctype/press_settings/press_settings.json @@ -211,6 +211,7 @@ "column_break_105", "agent_github_access_token", "branch", + "secret", "lets_encrypt_section", "certbot_directory", "webroot_directory", @@ -294,7 +295,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", @@ -360,7 +364,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", @@ -400,7 +407,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", @@ -412,7 +422,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", @@ -423,20 +437,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", @@ -469,14 +493,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", @@ -510,14 +540,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", @@ -533,7 +570,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", @@ -560,8 +600,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", @@ -595,7 +642,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", @@ -667,7 +717,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", @@ -716,7 +769,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", @@ -738,7 +794,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", @@ -749,9 +808,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", @@ -861,7 +931,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", @@ -891,7 +964,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", @@ -966,7 +1042,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", @@ -1027,7 +1107,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", @@ -1043,20 +1126,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", @@ -1072,7 +1167,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", @@ -1094,7 +1192,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", @@ -1174,9 +1275,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", @@ -1189,7 +1299,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", @@ -1234,7 +1347,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", @@ -1272,7 +1388,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", @@ -1295,7 +1414,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", @@ -1333,7 +1455,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", @@ -1392,8 +1517,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", @@ -1450,13 +1582,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", @@ -1555,7 +1694,10 @@ "fieldtype": "Section Break", "label": "Chat Support" }, - { "fieldname": "column_break_srse", "fieldtype": "Column Break" }, + { + "fieldname": "column_break_srse", + "fieldtype": "Column Break" + }, { "default": "0", "fieldname": "enable_chat", @@ -1586,12 +1728,21 @@ "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", "fieldtype": "Check", "label": "Auto Upgrade Dependencies" + }, + { + "fieldname": "secret", + "fieldtype": "Password", + "label": "Secret" } ], "issingle": 1, diff --git a/press/press/doctype/press_settings/press_settings.py b/press/press/doctype/press_settings/press_settings.py index 099808087ed..a2ce10d2b9c 100644 --- a/press/press/doctype/press_settings/press_settings.py +++ b/press/press/doctype/press_settings/press_settings.py @@ -213,6 +213,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 e04fd5f0ece..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], @@ -14,6 +14,16 @@ frappe.ui.form.on('Proxy Server', { true, frm.doc.is_server_setup, ], + [ + __( + frm.doc.is_agent_auth_setup + ? 'Regenerate Agent Token' + : 'Setup Agent Auth', + ), + 'setup_agent_auth', + false, + frm.doc.is_server_setup, + ], [ __('Install Filebeat'), 'install_filebeat', @@ -113,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', @@ -187,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) { @@ -201,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 297b828a0d7..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,6 +132,9 @@ def _setup_server(self): else: kibana_password = None + secret = self._generate_secret() + agent_token = self.sign_agent_token(secret) + try: ansible = Ansible( playbook="self_hosted_proxy.yml" if getattr(self, "is_self_hosted", False) else "proxy.yml", @@ -145,6 +154,7 @@ def _setup_server(self): "certificate_full_chain": certificate.full_chain, "certificate_intermediate_chain": certificate.intermediate_chain, "press_url": frappe.utils.get_url(), + "agent_token": agent_token, }, ) play = ansible.run() @@ -152,6 +162,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: 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", diff --git a/press/press/doctype/registry_server/registry_server.js b/press/press/doctype/registry_server/registry_server.js index dcaff487fce..4ee332f8d69 100644 --- a/press/press/doctype/registry_server/registry_server.js +++ b/press/press/doctype/registry_server/registry_server.js @@ -3,11 +3,21 @@ 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], + [ + __( + frm.doc.is_agent_auth_setup + ? 'Regenerate Agent Token' + : 'Setup Agent Auth', + ), + 'setup_agent_auth', + false, + frm.doc.is_server_setup, + ], [ __('Rewrite Config'), 'rewrite_config', @@ -48,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( @@ -127,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 09bf8db49e7..1aaf2df3f6b 100644 --- a/press/press/doctype/server/server.js +++ b/press/press/doctype/server/server.js @@ -10,14 +10,14 @@ frappe.ui.form.on('Server', { is_server_setup: 1, exclude_from_auto_selection: 0, }, - }; - }); + } + }) }, refresh: function (frm) { frm.add_web_link( `/dashboard/servers/${frm.doc.name}`, __('Visit Dashboard'), - ); + ) const ping_actions = [ [__('Ping Agent'), 'ping_agent', false, frm.doc.is_server_setup], @@ -34,34 +34,34 @@ frappe.ui.form.on('Server', { true, !frm.doc.is_server_prepared, ], - ]; + ] for (const [label, method, confirm, condition] of ping_actions) { if (!condition || typeof condition === 'undefined') { - continue; + continue } async function callback() { if (confirm && !(await frappe_confirm(label))) { - return; + return } - const res = await frm.call(method); + const res = await frm.call(method) if (res.message && method == 'ping_agent_job') { frappe.msgprint( `Agejt Job ${res?.message} created.`, - ); + ) } else if (res.message) { - frappe.msgprint(res.message); + frappe.msgprint(res.message) } else { - frm.refresh(); + frm.refresh() } } - frm.add_custom_button(label, callback, __('Ping')); + frm.add_custom_button(label, callback, __('Ping')) } - [ + ;[ [__('Update Agent'), 'update_agent', true, frm.doc.is_server_setup], [ __('Install Filebeat'), @@ -167,6 +167,16 @@ frappe.ui.form.on('Server', { false, frm.doc.is_server_setup, ], + [ + __( + frm.doc.is_agent_auth_setup + ? 'Regenerate Agent Token' + : 'Setup Agent Auth', + ), + 'setup_agent_auth', + false, + frm.doc.is_server_setup, + ], [ __('Start Active Benches'), 'start_active_benches', @@ -298,26 +308,26 @@ frappe.ui.form.on('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) { if (frm.doc.is_primary) { @@ -340,13 +350,13 @@ frappe.ui.form.on('Server', { server_plan: server_plan, }) .then((r) => { - frm.refresh(); - }); + frm.refresh() + }) }, - ); + ) }, __('Actions'), - ); + ) } frm.add_custom_button( @@ -363,18 +373,18 @@ frappe.ui.form.on('Server', { default: 4, }, ], - }); + }) dialog.set_primary_action(__('Increase Swap'), (args) => { frm.call('increase_swap', args).then(() => { - dialog.hide(); - frm.refresh(); - }); - }); - dialog.show(); + dialog.hide() + frm.refresh() + }) + }) + dialog.show() }, __('Actions'), - ); + ) frm.add_custom_button( __('Reset Swap'), () => { @@ -391,18 +401,18 @@ frappe.ui.form.on('Server', { default: 1, }, ], - }); + }) dialog.set_primary_action(__('Reset Swap'), (args) => { frm.call('reset_swap', args).then(() => { - dialog.hide(); - frm.refresh(); - }); - }); - dialog.show(); + dialog.hide() + frm.refresh() + }) + }) + dialog.show() }, __('Actions'), - ); + ) frm.add_custom_button( __('Snapshot Both Servers'), @@ -419,25 +429,25 @@ frappe.ui.form.on('Server', { default: 1, }, ], - }); + }) dialog.set_primary_action(__('Submit'), (args) => { frm.call('create_snapshot', args).then(() => { - dialog.hide(); - frm.refresh(); - }); - }); - dialog.show(); + dialog.hide() + frm.refresh() + }) + }) + dialog.show() }, __('Actions'), - ); + ) } }, hostname: function (frm) { - press.set_hostname_abbreviation(frm); + press.set_hostname_abbreviation(frm) }, -}); +}) async function frappe_confirm(label) { return new Promise((r) => { @@ -445,6 +455,6 @@ async function frappe_confirm(label) { `Are you sure you want to ${label.toLowerCase()}?`, () => r(true), () => r(false), - ); - }); + ) + }) } diff --git a/press/press/doctype/server/server.json b/press/press/doctype/server/server.json index fd506ed2993..cbbb9ccf246 100644 --- a/press/press/doctype/server/server.json +++ b/press/press/doctype/server/server.json @@ -57,6 +57,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", @@ -227,7 +229,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 +251,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", @@ -786,6 +790,19 @@ "fieldtype": "Int", "label": "Supported Site Quota", "read_only_depends_on": "eval:!!doc.__islocal" + }, + { + "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": [ @@ -798,7 +815,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 +836,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 ede9d5e6247..7ca351f8ed5 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -9,12 +9,14 @@ 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 frappe import _ @@ -51,9 +53,7 @@ from press.utils import fmt_timedelta, log_error if typing.TYPE_CHECKING: - from press.infrastructure.doctype.arm_build_record.arm_build_record import ( - ARMBuildRecord, - ) + from press.infrastructure.doctype.arm_build_record.arm_build_record import ARMBuildRecord 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 @@ -66,6 +66,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 @@ -1273,6 +1274,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" @@ -1982,6 +1987,57 @@ def _get_dependency_version(self, candidate: str, dependency: str) -> str: "version", ) + def sign_agent_token(self, secret): + expires_in = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=90) + + payload = { + "server": self.name, + "server_type": self.doctype, + "exp": int(expires_in.timestamp()), # 3 months + "jti": str(uuid.uuid4()), + } + + token = jwt.encode(payload, secret, algorithm="HS256") + + if not self.is_agent_auth_setup: + self.db_set("is_agent_auth_setup", 1) + + return token + + def _generate_secret(self): + press_settings: PressSettings = frappe.get_single("Press Settings") + secret = press_settings.get_value("secret") + + if secret: + secret = press_settings.get_password("secret") + else: + secret = frappe.generate_hash(length=64) + + press_settings.secret = secret + press_settings.save(ignore_permissions=True) + + return secret + + def _setup_agent_auth(self): + try: + secret = self._generate_secret() + agent_token = self.sign_agent_token(secret) + + ansible = Ansible( + playbook="setup_agent_auth.yml", + server=self, + user=self._ssh_user(), + port=self._ssh_port(), + variables={"agent_token": agent_token}, + ) + result = ansible.run() + if result.status != "Success": + log_error("Agent auth setup playbook failed", server=self.as_dict()) + return + + except Exception: + log_error("Agent Auth Setup Exception", server=self.as_dict()) + @frappe.whitelist() def collect_arm_images(self) -> str: """Collect arm build images of all active benches on VM""" @@ -2769,6 +2825,16 @@ def _migrate_to_cgroup_v2(self): except Exception: 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() + else: + agent.disable_feature_flag() + def _create_static_ip_log(self): if not self.team or not frappe.db.get_value( "Static IP Plan", {"provider": self.provider, "enabled": 1}, cache=True @@ -2799,6 +2865,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 @@ -2823,6 +2890,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 @@ -2894,7 +2962,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): @@ -2905,18 +2973,12 @@ def validate_managed_database_service(self): else: self.managed_database_service = "" - def on_update(self): # noqa: C901 - # 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() + def on_update(self): + 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) @@ -2932,6 +2994,29 @@ def on_update(self): # noqa: C901 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 @@ -2942,9 +3027,6 @@ def on_update(self): # noqa: C901 "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." ) - if self.is_new() and is_dedicated_server(self.name): - self.set_dedicated_server_site_warranty_quota_and_cooldown() - def update_db_server(self): if not self.database_server: return @@ -3233,6 +3315,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") + secret = self._generate_secret() + agent_token = self.sign_agent_token(secret) # If database server is set, then define db port under configuration db_port = ( @@ -3266,6 +3350,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(), }, @@ -3276,6 +3361,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() diff --git a/press/press/doctype/trace_server/trace_server.js b/press/press/doctype/trace_server/trace_server.js index 0023410b338..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], @@ -30,6 +30,16 @@ frappe.ui.form.on('Trace Server', { false, frm.doc.is_server_setup, ], + [ + __( + frm.doc.is_agent_auth_setup + ? 'Regenerate Agent Token' + : '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( @@ -41,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/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 0887f5c45e5..284f88cb17e 100644 --- a/press/tests/test_agent.py +++ b/press/tests/test_agent.py @@ -1,9 +1,14 @@ # Copyright (c) 2024, Frappe and contributors # For license information, please see license.txt + +from datetime import datetime, timedelta, timezone + import frappe +import jwt import requests import responses +from frappe.exceptions import ValidationError from frappe.tests.utils import FrappeTestCase from press.agent import Agent, AgentRequestSkippedException @@ -122,3 +127,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(ValidationError, 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", + )