From b56f07be4256867c8af5b40d6189970b5c9b596e Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Mon, 19 Jan 2026 14:38:51 +0100 Subject: [PATCH 1/6] feature: adds server for host machine --- update_server.py | 103 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 update_server.py diff --git a/update_server.py b/update_server.py new file mode 100644 index 00000000..cac770d2 --- /dev/null +++ b/update_server.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python +import json +import logging +import os +import subprocess +import time + +RCSTACK_PATH_NAME = "RCSTACK_PATH" +RCSTACK_PATH = os.environ.get(RCSTACK_PATH_NAME) +RCSTACK_LOG = os.environ.get("RCSTACK_LOG", "./rcstack_status.log") + +try: + # Py3 + from http.server import BaseHTTPRequestHandler, HTTPServer +except ImportError: + # Py2 + from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer + + +log = logging.getLogger("mini-server") + + +class RcstackUpdater(BaseHTTPRequestHandler): + def __init__(self, request, client_address, server): + super().__init__(request, client_address, server) + + def _send_json_ok(self): + body = json.dumps({"status": "ok"}).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path == "/health": + return self._send_json_ok() + elif self.path == "/update-rcstack": + ok, err_msg = self.spawn_rcstack_status() + if not ok: + self._send_http_error(err_msg.encode("utf-8"), status_code=500) + return + + self._send_http_error(b"not found", status_code=404) + + def spawn_rcstack_status(self): + if not RCSTACK_PATH: + return False, f"{RCSTACK_PATH_NAME} env variable not set" + + if not os.path.exists(RCSTACK_PATH) or not os.path.isfile(RCSTACK_PATH): + return False, f"{RCSTACK_PATH} does not exist" + + rcstack = os.path.expanduser(RCSTACK_PATH) + + # Append mode; don't use PIPE unless you read it. + out = open(RCSTACK_LOG, "ab") + + stamp = time.strftime("%Y-%m-%d %H:%M:%S").encode("utf-8") + out.write(b"[" + stamp + b"] spawn: " + rcstack.encode("utf-8") + b" status\n") + out.flush() + + # Detach from the request handler process group/session. + # start_new_session=True is Py3; preexec_fn=os.setsid works in Py2/3 on Unix. + try: + rcstack_dir = os.path.dirname(rcstack) + + subprocess.Popen( + [rcstack, "status"], + cwd=rcstack_dir, + stdout=out, + stderr=out, + close_fds=True, + preexec_fn=os.setsid, # Unix only + ) + finally: + out.close() + + return True, None + + def _send_http_error(self, msg, status_code): + self.send_response(status_code) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(msg) + + def log_message(self, fmt, *args): + log.info("%s - %s", self.address_string(), fmt % args) + + +def main(): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + host = "127.0.0.1" # allow only run within the system, no external connections allowed + port = 10025 + httpd = HTTPServer((host, port), RcstackUpdater) + log.info("listening on http://%s:%d", host, port) + httpd.serve_forever() + + +if __name__ == "__main__": + main() From ad3ae1737e38ce39ca3414e8d5bb3183d87aca8f Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Sun, 25 Jan 2026 12:01:17 +0100 Subject: [PATCH 2/6] feature: adds state and locking --- update_server.py | 109 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 81 insertions(+), 28 deletions(-) diff --git a/update_server.py b/update_server.py index cac770d2..feeed1fe 100644 --- a/update_server.py +++ b/update_server.py @@ -4,6 +4,7 @@ import logging import os import subprocess import time +import threading RCSTACK_PATH_NAME = "RCSTACK_PATH" RCSTACK_PATH = os.environ.get(RCSTACK_PATH_NAME) @@ -16,64 +17,118 @@ except ImportError: # Py2 from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer +try: + from shlex import quote as shell_quote # Py3 +except ImportError: + from pipes import quote as shell_quote # Py2 (Unix) -log = logging.getLogger("mini-server") +STATUS = {"state": "ready"} # "ready" or "processing" + + +def _log_line(text, log_path): + # compatible with python 2 + out = open(log_path, "ab") + try: + if not isinstance(text, bytes): + text = text.encode("utf-8") + out.write(text + b"\n") + out.flush() + finally: + out.close() + + +def _watch_process(popen_obj, log_path): + try: + rc = popen_obj.wait() + stamp = time.strftime("%Y-%m-%d %H:%M:%S") + _log_line("[" + stamp + "] finished rc=" + str(rc), log_path=log_path) + except Exception as e: + stamp = time.strftime("%Y-%m-%d %H:%M:%S") + _log_line("[" + stamp + "] watcher error: " + str(e), log_path=log_path) + finally: + STATUS["state"] = "ready" class RcstackUpdater(BaseHTTPRequestHandler): def __init__(self, request, client_address, server): - super().__init__(request, client_address, server) + BaseHTTPRequestHandler.__init__(self, request, client_address, server) - def _send_json_ok(self): - body = json.dumps({"status": "ok"}).encode("utf-8") - self.send_response(200) + def _send_json(self, obj, code=200): + body = json.dumps(obj).encode("utf-8") + self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) + def _send_json_ok(self): + return self._send_json({"status": "ok"}) + def do_GET(self): if self.path == "/health": return self._send_json_ok() - elif self.path == "/update-rcstack": - ok, err_msg = self.spawn_rcstack_status() - if not ok: - self._send_http_error(err_msg.encode("utf-8"), status_code=500) - return - self._send_http_error(b"not found", status_code=404) + elif self.path == "/state": + return self._send_json({"state": STATUS["state"]}) + + elif self.path == "/update-rcstack": + # If already processing, return ok and do not spawn a new process. + if STATUS["state"] != "ready": + return self._send_json({"status": "ok", "state": STATUS["state"]}) + + ok, err_msg = self.spawn_rcstack_status() + # If spawn failed for real reasons (bad env/path/etc.), return 500 + if not ok: + return self._send_http_error(err_msg.encode("utf-8"), status_code=500) + + return self._send_json({"status": "ok", "state": STATUS["state"]}) + + return self._send_http_error(b"not found", status_code=404) def spawn_rcstack_status(self): if not RCSTACK_PATH: - return False, f"{RCSTACK_PATH_NAME} env variable not set" + return False, "%s env variable not set" % (RCSTACK_PATH_NAME,) if not os.path.exists(RCSTACK_PATH) or not os.path.isfile(RCSTACK_PATH): - return False, f"{RCSTACK_PATH} does not exist" + return False, "%s does not exist" % (RCSTACK_PATH,) rcstack = os.path.expanduser(RCSTACK_PATH) + rcstack_dir = os.path.dirname(rcstack) + log_path = os.path.join(rcstack_dir, RCSTACK_LOG) - # Append mode; don't use PIPE unless you read it. - out = open(RCSTACK_LOG, "ab") + STATUS["state"] = "processing" - stamp = time.strftime("%Y-%m-%d %H:%M:%S").encode("utf-8") - out.write(b"[" + stamp + b"] spawn: " + rcstack.encode("utf-8") + b" status\n") - out.flush() + stamp = time.strftime("%Y-%m-%d %H:%M:%S") + _log_line("[%s] spawn: %s status" % (stamp, rcstack), log_path=log_path) - # Detach from the request handler process group/session. - # start_new_session=True is Py3; preexec_fn=os.setsid works in Py2/3 on Unix. + out = open(log_path, "ab") try: - rcstack_dir = os.path.dirname(rcstack) + cmd = "%s self-update && %s self-upgrade" % (shell_quote(rcstack), shell_quote(rcstack)) - subprocess.Popen( - [rcstack, "status"], + p = subprocess.Popen( + ["/bin/sh", "-c", cmd], cwd=rcstack_dir, stdout=out, stderr=out, close_fds=True, preexec_fn=os.setsid, # Unix only ) + except Exception as e: + STATUS["state"] = "ready" + _log_line("[%s] spawn error: %s" % (stamp, str(e)), log_path=log_path) + return False, "spawn failed: %s" % (str(e),) finally: - out.close() + try: + out.close() + except Exception: + pass + + t = threading.Thread(target=_watch_process, args=(p, log_path)) + try: + t.daemon = True + except Exception: + t.setDaemon(True) # Py2 + t.start() return True, None @@ -83,18 +138,16 @@ class RcstackUpdater(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(msg) - def log_message(self, fmt, *args): - log.info("%s - %s", self.address_string(), fmt % args) - def main(): logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) - host = "127.0.0.1" # allow only run within the system, no external connections allowed + host = "127.0.0.1" port = 10025 httpd = HTTPServer((host, port), RcstackUpdater) + log = logging.getLogger("mini-server") log.info("listening on http://%s:%d", host, port) httpd.serve_forever() From bb1bac5a772b45c8fdae2f4fb829d78d99ea15bb Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Sun, 25 Jan 2026 13:57:44 +0100 Subject: [PATCH 3/6] feature: integrated update service to CE --- rhodecode/apps/admin/__init__.py | 9 +++++ rhodecode/apps/admin/update_service.py | 38 +++++++++++++++++++ rhodecode/apps/admin/views/system_info.py | 34 +++++++++++++++++ .../admin/settings/settings_system.mako | 3 ++ update_server.py | 22 +++-------- 5 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 rhodecode/apps/admin/update_service.py diff --git a/rhodecode/apps/admin/__init__.py b/rhodecode/apps/admin/__init__.py index d43e1077..2e4d7aa2 100644 --- a/rhodecode/apps/admin/__init__.py +++ b/rhodecode/apps/admin/__init__.py @@ -209,6 +209,15 @@ def admin_routes(config): renderer="rhodecode:templates/admin/settings/settings_system_update.mako", ) + config.add_route(name="settings_system_info_rcstack_update", pattern="/settings/system/rcstack/update") + config.add_view( + AdminSystemInfoSettingsView, + attr="settings_system_info_rcstack_update", + route_name="settings_system_info_rcstack_update", + request_method="GET", + renderer="rhodecode:templates/admin/settings/settings_system_update.mako", + ) + config.add_route(name="admin_settings_exception_tracker", pattern="/settings/exceptions") config.add_view( ExceptionsTrackerView, diff --git a/rhodecode/apps/admin/update_service.py b/rhodecode/apps/admin/update_service.py new file mode 100644 index 00000000..8aee9cd0 --- /dev/null +++ b/rhodecode/apps/admin/update_service.py @@ -0,0 +1,38 @@ +import logging +import os + +import requests + +from rhodecode.lib.type_utils import str2bool + + +class RcStackUpdateService: + class State: + READY = "ready" + PROCESSING = "processing" + + def __init__(self, server_host: str, server_port: int): + self.host = server_host + self.port = server_port + self.log = logging.getLogger(__name__) + + def feature_available(self): + if str2bool(os.environ.get("DISABLE_RC_UPDATE_SERVICE", "false")) or self.host is None: + return False + + try: + self.get_state() + return True + except Exception as e: + self.log.error(f"Server on host machine not available, error: {e}") + return False + + def get_state(self): + url_base = f"http://{self.host}:{self.port}" + url = f"{url_base}/state" + return requests.get(url).json().get("state", None) + + def rcstack_update(self): + url_base = f"http://{self.host}:{self.port}" + url = f"{url_base}/rcstack/update" + return requests.get(url).json() diff --git a/rhodecode/apps/admin/views/system_info.py b/rhodecode/apps/admin/views/system_info.py index ce75a584..fd68e1cd 100644 --- a/rhodecode/apps/admin/views/system_info.py +++ b/rhodecode/apps/admin/views/system_info.py @@ -22,9 +22,12 @@ import urllib.error import urllib.parse import os +from pyramid.httpexceptions import HTTPFound + import rhodecode from rhodecode.apps._base import BaseAppView from rhodecode.apps._base.navigation import navigation_list +from rhodecode.apps.admin.update_service import RcStackUpdateService from rhodecode.lib import helpers as h from rhodecode.lib.auth import LoginRequired, HasPermissionAllDecorator from rhodecode.lib.utils2 import str2bool @@ -112,6 +115,20 @@ class AdminSystemInfoSettingsView(BaseAppView): update_state = ( {"type": "warning", "message": "New version available: {}".format(version)} if is_outdated else {} ) + + update_service = self._get_rc_update_service() + update_feature_available = update_service.feature_available() + + update_literal = "" + if update_feature_available: + update_service_state = update_service.get_state() + if update_service_state == RcStackUpdateService.State.READY: + update_literal = '
%s.' % (_("Update RcStack")) + else: + update_literal = "
%s." % ( + _("Update in progress, please wait. You can check logs on the host machine.") + ) + c.data_items = [ # update info ( @@ -119,6 +136,7 @@ class AdminSystemInfoSettingsView(BaseAppView): h.literal( '%s.' % (_("Check for updates")) + "
%s." % (update_info_msg) + + update_literal ), "", ), @@ -206,6 +224,22 @@ class AdminSystemInfoSettingsView(BaseAppView): h.flash("You are not allowed to do this", category="warning") return self._get_template_context(c) + @LoginRequired() + @HasPermissionAllDecorator("hg.admin") + def settings_system_info_rcstack_update(self): + update_service = self._get_rc_update_service() + update_feature_available = update_service.feature_available() + + if update_feature_available: + response = update_service.rcstack_update() + log.debug(f"update server response: {response}") + return HTTPFound(h.route_path("admin_settings_system")) + + def _get_rc_update_service(self) -> RcStackUpdateService: + host = os.environ.get("RC_UPDATE_HOST", None) + port = os.environ.get("RC_UPDATE_PORT", 10025) + return RcStackUpdateService(server_host=host, server_port=port) + @LoginRequired() @HasPermissionAllDecorator("hg.admin") def settings_system_info_check_update(self): diff --git a/rhodecode/templates/admin/settings/settings_system.mako b/rhodecode/templates/admin/settings/settings_system.mako index edaf81c0..5c359ab0 100644 --- a/rhodecode/templates/admin/settings/settings_system.mako +++ b/rhodecode/templates/admin/settings/settings_system.mako @@ -100,4 +100,7 @@ $('#update_notice').show(); $('#update_notice').load("${h.route_path('admin_settings_system_update', _query={'ver': request.GET.get('ver')})}"); }) + $('#rcstack_update').on('click', function(e){ + window.location.href = "${h.route_path('settings_system_info_rcstack_update')}"; + }) diff --git a/update_server.py b/update_server.py index feeed1fe..e0d31b2c 100644 --- a/update_server.py +++ b/update_server.py @@ -61,17 +61,11 @@ class RcstackUpdater(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) - def _send_json_ok(self): - return self._send_json({"status": "ok"}) - def do_GET(self): - if self.path == "/health": - return self._send_json_ok() - - elif self.path == "/state": + if self.path == "/state": return self._send_json({"state": STATUS["state"]}) - elif self.path == "/update-rcstack": + elif self.path == "/rcstack/update": # If already processing, return ok and do not spawn a new process. if STATUS["state"] != "ready": return self._send_json({"status": "ok", "state": STATUS["state"]}) @@ -139,16 +133,10 @@ class RcstackUpdater(BaseHTTPRequestHandler): self.wfile.write(msg) -def main(): - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)s %(name)s: %(message)s", - ) - host = "127.0.0.1" - port = 10025 +def main(host="127.0.0.1", port=10025): httpd = HTTPServer((host, port), RcstackUpdater) - log = logging.getLogger("mini-server") - log.info("listening on http://%s:%d", host, port) + stamp = time.strftime("%Y-%m-%d %H:%M:%S") + print("[%s] listening on http://%s:%d" % (stamp, host, port)) httpd.serve_forever() From f7976ba1986838c2a8e5cbea3124d39c07845300 Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Sun, 25 Jan 2026 16:08:51 +0100 Subject: [PATCH 4/6] feature: reformat --- update_server.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/update_server.py b/update_server.py index e0d31b2c..f6152fca 100644 --- a/update_server.py +++ b/update_server.py @@ -1,15 +1,10 @@ #!/usr/bin/env python import json -import logging import os import subprocess import time import threading -RCSTACK_PATH_NAME = "RCSTACK_PATH" -RCSTACK_PATH = os.environ.get(RCSTACK_PATH_NAME) -RCSTACK_LOG = os.environ.get("RCSTACK_LOG", "./rcstack_status.log") - try: # Py3 from http.server import BaseHTTPRequestHandler, HTTPServer @@ -22,6 +17,9 @@ try: except ImportError: from pipes import quote as shell_quote # Py2 (Unix) +RCSTACK_PATH_NAME = "RCSTACK_PATH" +RCSTACK_PATH = os.environ.get(RCSTACK_PATH_NAME) +RCSTACK_LOG = os.environ.get("RCSTACK_LOG", "./rcstack_status.log") STATUS = {"state": "ready"} # "ready" or "processing" From 9f2d7c7e123c4c0336210d2327e449754802d1e7 Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Tue, 27 Jan 2026 13:21:01 +0100 Subject: [PATCH 5/6] feature: changes names --- rhodecode/apps/admin/views/system_info.py | 6 ++++-- update_server.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/rhodecode/apps/admin/views/system_info.py b/rhodecode/apps/admin/views/system_info.py index fd68e1cd..24102b9e 100644 --- a/rhodecode/apps/admin/views/system_info.py +++ b/rhodecode/apps/admin/views/system_info.py @@ -123,10 +123,12 @@ class AdminSystemInfoSettingsView(BaseAppView): if update_feature_available: update_service_state = update_service.get_state() if update_service_state == RcStackUpdateService.State.READY: - update_literal = '
%s.' % (_("Update RcStack")) + update_literal = '
%s.' % ( + _("Update Rhodecode to latest version") + ) else: update_literal = "
%s." % ( - _("Update in progress, please wait. You can check logs on the host machine.") + _("Update in progress, please wait. You can check logs on the host machine") ) c.data_items = [ diff --git a/update_server.py b/update_server.py index f6152fca..5b01759f 100644 --- a/update_server.py +++ b/update_server.py @@ -19,7 +19,7 @@ except ImportError: RCSTACK_PATH_NAME = "RCSTACK_PATH" RCSTACK_PATH = os.environ.get(RCSTACK_PATH_NAME) -RCSTACK_LOG = os.environ.get("RCSTACK_LOG", "./rcstack_status.log") +RCSTACK_LOG_NAME = os.environ.get("RCSTACK_LOG_NAME", "./rcstack_status.log") STATUS = {"state": "ready"} # "ready" or "processing" @@ -86,7 +86,7 @@ class RcstackUpdater(BaseHTTPRequestHandler): rcstack = os.path.expanduser(RCSTACK_PATH) rcstack_dir = os.path.dirname(rcstack) - log_path = os.path.join(rcstack_dir, RCSTACK_LOG) + log_path = os.path.join(rcstack_dir, RCSTACK_LOG_NAME) STATUS["state"] = "processing" From 7fd0a9492262c2755da2373c7e0b67daf010e4ae Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Tue, 27 Jan 2026 15:54:43 +0100 Subject: [PATCH 6/6] feature: fixes update command --- update_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/update_server.py b/update_server.py index 5b01759f..7bba27cf 100644 --- a/update_server.py +++ b/update_server.py @@ -95,7 +95,7 @@ class RcstackUpdater(BaseHTTPRequestHandler): out = open(log_path, "ab") try: - cmd = "%s self-update && %s self-upgrade" % (shell_quote(rcstack), shell_quote(rcstack)) + cmd = "%s self-update && %s stack-upgrade all" % (shell_quote(rcstack), shell_quote(rcstack)) p = subprocess.Popen( ["/bin/sh", "-c", cmd],