Ready to Deploy Your First Site?
++ Get started in minutes with your own infrastructure. +
+ +diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e099a71..939cc1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,12 +48,12 @@ jobs: - name: Setup Python uses: actions/setup-python@v4 with: - python-version: '3.10' + python-version: '3.14' - name: Setup Node uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 24 check-latest: true - name: Cache pip diff --git a/nano_press/api.py b/nano_press/api.py index d8a9b79..d751114 100644 --- a/nano_press/api.py +++ b/nano_press/api.py @@ -81,3 +81,78 @@ def check_domain_resolves_to_ip(domain: str, expected_ip: str) -> dict: except Exception as e: frappe.log_error(f"Error occurred while checking domain {domain}: {e}") return {"success": False, "resolved_ips": [], "message": f"Error: {e}"} + + +@frappe.whitelist() +def register_custom_app( + app_name: str, github_url: str, branch: str, token: str | None = None, order: int | None = None +) -> dict: + try: + if not app_name or not github_url or not branch: + frappe.throw(_("app_name, github_url, and branch are required")) + + app_name_normalized = app_name.lower().strip() + + if frappe.db.exists("Apps", app_name_normalized): + app_doc = frappe.get_doc("Apps", app_name_normalized) + app_doc.repo_url = github_url + app_doc.branch = branch + if token: + app_doc.pat_token = token + if order is not None: + app_doc.order = order + app_doc.save(ignore_permissions=True) + else: + app_doc = frappe.get_doc( + { + "doctype": "Apps", + "app_name": app_name_normalized, + "repo_url": github_url, + "branch": branch, + "pat_token": token or "", + "is_custom": 1, + "enabled": 1, + "order": order if order is not None else 999, + } + ) + app_doc.insert(ignore_permissions=True) + + frappe.db.commit() # nosemgrep: frappe-semgrep-rules.rules.frappe-manual-commit + + return { + "success": True, + "app_reference_id": app_doc.name, + "message": _("Custom app registered securely"), + } + + except Exception as e: + frappe.log_error(f"Error registering custom app: {e}") + frappe.db.rollback() + return {"success": False, "message": str(e)} + + +@frappe.whitelist() +def delete_custom_app(app_name: str) -> dict: + try: + if not app_name: + frappe.throw(_("app_name is required")) + + app_name_normalized = app_name.lower().strip() + + if not frappe.db.exists("Apps", app_name_normalized): + return {"success": True, "message": _("App not found or already deleted")} + + app_doc = frappe.get_doc("Apps", app_name_normalized) + + if not app_doc.is_custom: + frappe.throw(_("Cannot delete non-custom apps")) + + frappe.delete_doc("Apps", app_name_normalized, ignore_permissions=True) + frappe.db.commit() # nosemgrep: frappe-semgrep-rules.rules.frappe-manual-commit + + return {"success": True, "message": _("Custom app deleted successfully")} + + except Exception as e: + frappe.log_error(f"Error deleting custom app: {e}") + frappe.db.rollback() + return {"success": False, "message": str(e)} diff --git a/nano_press/fixtures/app_version.json b/nano_press/fixtures/app_version.json new file mode 100644 index 0000000..b3830de --- /dev/null +++ b/nano_press/fixtures/app_version.json @@ -0,0 +1,26 @@ +[ + { + "docstatus": 0, + "doctype": "App Version", + "modified": "2025-12-27 17:08:23.781012", + "name": "version-14", + "scrubbed_version": "version-14", + "version": "Version 14" + }, + { + "docstatus": 0, + "doctype": "App Version", + "modified": "2025-12-27 17:08:23.781478", + "name": "nightly", + "scrubbed_version": "nightly", + "version": "Nightly" + }, + { + "docstatus": 0, + "doctype": "App Version", + "modified": "2025-12-27 17:08:23.780453", + "name": "version-15", + "scrubbed_version": "version-15", + "version": "Version 15" + } +] diff --git a/nano_press/hooks.py b/nano_press/hooks.py index c8dc295..3017754 100644 --- a/nano_press/hooks.py +++ b/nano_press/hooks.py @@ -30,6 +30,7 @@ "dt": "Custom DocPerm", "filters": {"parent": ("in", ("Frappe Site", "Server", "Apps", "Custom Image", "Ansible Log"))}, }, + {"dt": "App Version"}, ] # Includes in
@@ -68,7 +69,7 @@ # ---------- # application home page (will override Website Settings) -# home_page = "login" +home_page = "index" # website user home page (by Role) # role_home_page = { @@ -151,13 +152,13 @@ # Scheduled Tasks # --------------- -scheduler_events = { - "cron": { - "*/5 * * * *": [ - "nano_press.utils.ansible_runner.ping_server", - ], - } -} +# scheduler_events = { +# "cron": { +# "*/5 * * * *": [ +# "nano_press.utils.ansible_runner.ping_server", +# ], +# } +# } # Testing diff --git a/nano_press/nano_press/doctype/app_version/__init__.py b/nano_press/nano_press/doctype/app_version/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nano_press/nano_press/doctype/app_version/app_version.json b/nano_press/nano_press/doctype/app_version/app_version.json new file mode 100644 index 0000000..a77ba44 --- /dev/null +++ b/nano_press/nano_press/doctype/app_version/app_version.json @@ -0,0 +1,48 @@ +{ + "actions": [], + "autoname": "", + "creation": "2025-12-27 00:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": ["version", "scrubbed_version"], + "fields": [ + { + "fieldname": "version", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Version", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "scrubbed_version", + "fieldtype": "Data", + "hidden": 1, + "label": "Scrubbed Version" + } + ], + "links": [], + "modified": "2025-12-27 16:34:33.317253", + "modified_by": "Administrator", + "module": "Nano Press", + "name": "App Version", + "naming_rule": "By script", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "read": 1, + "role": "System Manager", + "write": 1 + }, + { + "read": 1, + "role": "Nano Press User" + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/nano_press/nano_press/doctype/app_version/app_version.py b/nano_press/nano_press/doctype/app_version/app_version.py new file mode 100644 index 0000000..55be9ca --- /dev/null +++ b/nano_press/nano_press/doctype/app_version/app_version.py @@ -0,0 +1,11 @@ +# Copyright (c) 2025, Build With Hussain and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class AppVersion(Document): + def autoname(self): + if self.version: + self.scrubbed_version = self.version.lower().replace(" ", "-") + self.name = self.scrubbed_version diff --git a/nano_press/nano_press/doctype/apps/apps.json b/nano_press/nano_press/doctype/apps/apps.json index 8fd8cbb..f567903 100644 --- a/nano_press/nano_press/doctype/apps/apps.json +++ b/nano_press/nano_press/doctype/apps/apps.json @@ -6,6 +6,7 @@ "engine": "InnoDB", "field_order": [ "app_name", + "app_logo", "scrubbed_name", "repo_url", "repository_owner", @@ -13,6 +14,8 @@ "branch", "order", "pat_token", + "column_break_eisq", + "is_custom", "is_public", "frappe", "enabled" @@ -51,11 +54,11 @@ "non_negative": 1 }, { - "depends_on": "eval: doc.is_public == 0", + "depends_on": "eval: doc.is_custom == 1", + "description": "Required for private repositories. If provided, the app will be marked as private.", "fieldname": "pat_token", - "fieldtype": "Data", - "label": "PAT Token", - "mandatory_depends_on": "eval: doc.is_private == 1" + "fieldtype": "Password", + "label": "PAT Token" }, { "fieldname": "scrubbed_name", @@ -86,12 +89,28 @@ "fieldname": "frappe", "fieldtype": "Check", "label": "Frappe" + }, + { + "fieldname": "column_break_eisq", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "is_custom", + "fieldtype": "Check", + "label": "Custom" + }, + { + "fieldname": "app_logo", + "fieldtype": "Attach Image", + "label": "App Logo" } ], "grid_page_length": 50, + "image_field": "app_logo", "index_web_pages_for_search": 1, "links": [], - "modified": "2025-10-24 19:24:48.880196", + "modified": "2025-12-26 22:09:46.554687", "modified_by": "Administrator", "module": "Nano Press", "name": "Apps", diff --git a/nano_press/nano_press/doctype/apps/apps.py b/nano_press/nano_press/doctype/apps/apps.py index 09d6f04..ca221ff 100644 --- a/nano_press/nano_press/doctype/apps/apps.py +++ b/nano_press/nano_press/doctype/apps/apps.py @@ -1,10 +1,39 @@ # Copyright (c) 2025, Venkatesh M and contributors # For license information, please see license.txt -# import frappe +import frappe from frappe.model.document import Document class Apps(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + app_name: DF.Data + branch: DF.Data + enabled: DF.Check + frappe: DF.Check + is_custom: DF.Check + is_public: DF.Check + order: DF.Int | None + pat_token: DF.Password | None + repo_url: DF.Data + repository_owner: DF.Data | None + scrubbed_name: DF.Data | None + + # end: auto-generated types + def before_insert(self): self.scrubbed_name = self.app_name.replace(" ", "_").lower() + + def validate(self): + if self.is_custom: + if self.pat_token: + self.is_public = 0 + else: + self.is_public = 1 diff --git a/nano_press/nano_press/doctype/custom_image/custom_image.json b/nano_press/nano_press/doctype/custom_image/custom_image.json index cd71faa..d6f957f 100644 --- a/nano_press/nano_press/doctype/custom_image/custom_image.json +++ b/nano_press/nano_press/doctype/custom_image/custom_image.json @@ -30,9 +30,9 @@ { "default": "version-15", "fieldname": "frappe_version", - "fieldtype": "Select", + "fieldtype": "Link", "label": "Frappe Version", - "options": "version-15\nversion-14" + "options": "App Version" }, { "fieldname": "column_break_zpqr", @@ -104,7 +104,7 @@ "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-10-23 23:50:38.572587", + "modified": "2025-12-27 17:02:53.063389", "modified_by": "Administrator", "module": "Nano Press", "name": "Custom Image", diff --git a/nano_press/nano_press/doctype/custom_image/custom_image.py b/nano_press/nano_press/doctype/custom_image/custom_image.py index 51f02bb..e07d408 100644 --- a/nano_press/nano_press/doctype/custom_image/custom_image.py +++ b/nano_press/nano_press/doctype/custom_image/custom_image.py @@ -3,7 +3,6 @@ import base64 import json -from typing import Any import frappe from frappe import _ @@ -14,214 +13,136 @@ class CustomImage(Document): def before_save(self): - self.apps_json_base64 = self.generate_apps_json_base64() - self.set_image_tag() + self.apps_json_base64 = self._generate_apps_json_base64() + self._set_image_tag() - def generate_apps_json(self) -> str: - """Generate apps.json content from this Custom Image's apps configuration. + def _set_image_tag(self): + import re - Returns: - JSON string in frappe_docker apps.json format + name = self.image_name.lower() + name = re.sub(r"\s+", "-", name) + name = re.sub(r"[^a-z0-9_.-]", "", name) + name = re.sub(r"[-_.]{2,}", "-", name) + name = name.lstrip(".-") - Raises: - frappe.ValidationError: If invalid app configuration found - """ - if not self.apps_config: - frappe.throw("No apps configured for this Custom Image") + sanitized_name = name if name else "image" + self.image_tag = f"{sanitized_name}:latest" - apps_list = [] + def _generate_apps_json(self): + if not self.apps_config: + frappe.throw(_("No apps configured for this Custom Image")) for app_item in self.apps_config: if not app_item.app_name: continue - try: - app_doc = frappe.get_cached_doc("Apps", app_item.app_name) - except frappe.DoesNotExistError: - frappe.throw(f"App '{app_item.app_name}' not found in Apps doctype") - + app_doc = frappe.get_cached_doc("Apps", app_item.app_name) if not app_doc.repo_url or not app_doc.branch: frappe.throw( - f"App '{app_item.app_name}' has incomplete configuration (missing repo_url or branch)" + _("App '{0}' has incomplete configuration (missing repo_url or branch)").format( + app_item.app_name + ) ) - app_config = {"url": self._build_repo_url(app_doc), "branch": app_doc.branch} + return json.dumps(self._sort_apps_by_order(), indent=2) - apps_list.append(app_config) + def _generate_apps_json_base64(self): + apps_json = self._generate_apps_json() + return base64.b64encode(apps_json.encode("utf-8")).decode("utf-8") - sorted_apps = self._sort_apps_by_order(apps_list) + def _build_repo_url(self, app_doc): + repo_url = (app_doc.repo_url or "").strip() - return json.dumps(sorted_apps, indent=2) + if not app_doc.is_public and app_doc.pat_token and repo_url.startswith("https://"): + url_parts = repo_url.replace("https://", "").split("/", 1) + if len(url_parts) == 2: + return f"https://{app_doc.pat_token}@{url_parts[0]}/{url_parts[1]}" - def set_image_tag(self) -> str: - """Generate image tag using just the image name.""" - clean_name = self.image_name.lower().replace(" ", "-") - self.image_tag = f"{clean_name}:latest" + return repo_url - def generate_apps_json_base64(self) -> str: - """Generate base64 encoded apps.json for docker build args. + def _sort_apps_by_order(self): + order_map = {} + for item in self.apps_config: + if item.app_name: + order_map[item.app_name] = frappe.db.get_value("Apps", item.app_name, "order") or 999 - Returns: - Base64 encoded JSON string ready for APPS_JSON_BASE64 env var - """ - apps_json = self.generate_apps_json() - return base64.b64encode(apps_json.encode("utf-8")).decode("utf-8") + sorted_config = sorted( + [item for item in self.apps_config if item.app_name], + key=lambda item: order_map.get(item.app_name, 999), + ) - def get_deployment_vars(self) -> dict: - """Prepare all variables needed for Image Build""" + sorted_list = [] + for item in sorted_config: + app_doc = frappe.get_cached_doc("Apps", item.app_name) + sorted_list.append( + { + "url": self._build_repo_url(app_doc), + "branch": app_doc.branch, + } + ) + + return sorted_list + def _get_deployment_vars(self): return { "image_name": self.image_name, "frappe_version": self.frappe_version, - "apps_json_base64": self.generate_apps_json_base64(), + "apps_json_base64": self.apps_json_base64, } - def _build_repo_url(self, app_doc) -> str: - """Build repository URL with PAT token if private repo. - - Args: - app_doc: Apps document - - Returns: - Repository URL formatted for git clone - """ - repo_url = app_doc.repo_url.strip() - - if app_doc.is_private and app_doc.pat_token: - # Convert https://github.com/owner/repo.git to https://PAT@github.com/owner/repo.git - if repo_url.startswith("https://"): - url_parts = repo_url.replace("https://", "").split("/", 1) - if len(url_parts) == 2: - domain = url_parts[0] - path = url_parts[1] - return f"https://{app_doc.pat_token}@{domain}/{path}" - - frappe.msgprint( - f"Warning: Private repo URL format might need manual adjustment for {app_doc.app_name}" - ) - - return repo_url - - def _sort_apps_by_order(self, apps_list: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Sort apps by order field from Apps doctype. - - Args: - apps_list: Generated apps configuration list - - Returns: - Sorted apps list by order priority - """ - app_order_map = {} - for app_item in self.apps_config: - if app_item.app_name: - try: - app_doc = frappe.get_cached_doc("Apps", app_item.app_name) - app_order_map[app_item.app_name] = app_doc.order or 999 - except frappe.DoesNotExistError: - app_order_map[app_item.app_name] = 999 - - app_names = [app_item.app_name for app_item in self.apps_config if app_item.app_name] - - apps_with_order = [] - for i, app_config in enumerate(apps_list): - app_name = app_names[i] if i < len(app_names) else None - order = app_order_map.get(app_name, 999) - apps_with_order.append((app_config, order)) - - sorted_apps_with_order = sorted(apps_with_order, key=lambda x: x[1]) - return [app_config for app_config, _ in sorted_apps_with_order] - - @frappe.whitelist() - def preview_apps_json_for_form(self) -> dict[str, Any]: - """Generate apps.json preview for display in the form. - - Returns: - Dict with formatted apps.json and metadata for UI display - """ - try: - apps_json = self.generate_apps_json() - apps_json_base64 = self.generate_apps_json_base64() - parsed_apps = json.loads(apps_json) - - return { - "success": True, - "apps_json": apps_json, - "apps_json_base64": apps_json_base64, - "app_count": len(parsed_apps), - "apps_summary": [ - { - "name": app.get("url", "").split("/")[-1].replace(".git", ""), - "url": app.get("url", ""), - "branch": app.get("branch", ""), - } - for app in parsed_apps - ], - } - except Exception as e: - return { - "success": False, - "error": str(e), - "apps_json": "", - "apps_json_base64": "", - "app_count": 0, - "apps_summary": [], - } - def build_custom_image(self): - try: - self.build_status = "Building" - self.save() - frappe.db.commit() + start_time = frappe.utils.now_datetime() - vars = self.get_deployment_vars() + try: result = run_playbook( - server_name=self.server_name, playbook_path="build_custom_image.yml", extra_vars=vars + server_name=self.server_name, + playbook_path="build_custom_image.yml", + extra_vars=self._get_deployment_vars(), ) if result.get("status") != "success": - self.build_status = "Failed" - self.save() - self._send_build_notification("error", result.get("message", "Unknown error")) - self._send_email_notification("error") - frappe.log_error(result.get("message"), _("Custom Image Build Failed")) - raise Exception(f"Build failed: {result.get('message', 'Unknown error')}") - - self.build_status = "Built" - self.built_at = frappe.utils.now_datetime() - self.build_duration = (self.built_at - self.creation).total_seconds() - self.save() - self._send_build_notification("success", "Image built successfully") - self._send_email_notification("success") - frappe.db.commit() + self._on_build_failure(result.get("message", "Unknown error")) + return - except Exception as e: - frappe.log_error(str(e), "Image Build Failed") - self.reload() - self.build_status = "Failed" - self.save() - frappe.db.commit() + self._on_build_success(start_time) + + except Exception: + frappe.db.rollback() + self._update_status("Failed") + frappe.log_error(title=_("Custom Image Build Failed")) raise - @frappe.whitelist() - def enqueue_build_custom_image(self): - """Enqueue the build process for this Custom Image.""" - frappe.enqueue_doc( + def _update_status(self, status): + frappe.db.set_value("Custom Image", self.name, "build_status", status, update_modified=False) + frappe.db.commit() # nosemgrep: frappe-semgrep-rules.rules.frappe-manual-commit + self._sync_status_to_frappe_sites() + + def _on_build_success(self, start_time): + end_time = frappe.utils.now_datetime() + duration = int((end_time - start_time).total_seconds()) + frappe.db.set_value( "Custom Image", self.name, - "build_custom_image", - queue="long", - timeout=60 * 20, - enqueue_after_commit=True, + { + "build_status": "Built", + "built_at": end_time, + "build_duration": duration, + }, ) - return {"status": "queued", "message": f"Build process for {self.name} has been queued."} + self.reload() + self._sync_status_to_frappe_sites() + self._notify("success", _("Image built successfully")) + + def _on_build_failure(self, error_message): + self._update_status("Failed") + self._notify("error", error_message) + frappe.log_error(message=error_message, title=_("Custom Image Build Failed")) - def _send_build_notification(self, status: str, message: str): - """Send real-time notification about build status. + def _notify(self, status, message): + self._send_realtime_notification(status, message) + self._send_email_notification(status) - Args: - status: 'success' or 'error' - message: Notification message - """ + def _send_realtime_notification(self, status, message): try: frappe.publish_realtime( event="custom_image_build_update", @@ -229,61 +150,45 @@ def _send_build_notification(self, status: str, message: str): "custom_image": self.name, "status": status, "message": message, - "build_status": self.build_status, - "timestamp": frappe.utils.now_datetime(), }, - user=frappe.session.user, + user=self.owner, ) frappe.get_doc( { "doctype": "Notification Log", - "subject": f"Custom Image Build: {self.image_name}", + "subject": _("Custom Image Build: {0}").format(self.image_name), "email_content": message, - "for_user": frappe.session.user, + "for_user": self.owner, "type": "Alert", "document_type": "Custom Image", "document_name": self.name, } ).insert(ignore_permissions=True) + except Exception: + frappe.log_error(title=_("Build Notification Failed")) - except Exception as e: - frappe.log_error(f"Failed to send build notification: {e!s}", "Build Notification") - - def _send_email_notification(self, status: str): - """Send email notification directly using Frappe's email system. - - Args: - status: 'success' or 'error' - """ + def _send_email_notification(self, status): try: - recipient = frappe.db.get_value("User", self.owner, "email") or frappe.session.user + recipient = frappe.db.get_value("User", self.owner, "email") if not recipient or "@" not in recipient: - frappe.log_error(f"Invalid email for user {self.owner}", "Email Notification") return + if status == "success": - subject = f"🚀 Custom Image Build Successful - {self.image_name}" + subject = _("Custom Image Build Successful - {0}").format(self.image_name) message = f""" -Your custom Docker image {self.image_name} has been built successfully!
- -Image Tag: {self.image_tag}
Build Duration: {self.build_duration} seconds
+Your custom Docker image {self.image_name} has been built successfully.
+Image Tag: {self.image_tag}
Server: {self.server_name}
Frappe Version: {self.frappe_version}
- -Your custom image is now ready to use in deployments!
+Build Duration: {self.build_duration or 0} seconds
""" else: - subject = f"❌ Custom Image Build Failed - {self.image_name}" + subject = _("Custom Image Build Failed - {0}").format(self.image_name) message = f""" -Unfortunately, your custom Docker image build encountered an error.
- +Your custom Docker image build encountered an error.
Image Name: {self.image_name}
Server: {self.server_name}
-Build Duration: {self.build_duration} seconds
-Please check the build log for more details.
""" @@ -294,134 +199,145 @@ def _send_email_notification(self, status: str): reference_doctype="Custom Image", reference_name=self.name, ) + except Exception: + frappe.log_error(title=_("Email Notification Failed")) + + def _sync_status_to_frappe_sites(self): + if not self.has_value_changed("build_status"): + return + + linked_sites = frappe.get_all( + "Frappe Site", + filters={ + "custom_image": self.name, + "status": ["in", ["Not Deployed", "Building", "Build Failed"]], + }, + fields=["name", "status", "owner"], + ) - except Exception as e: - frappe.log_error(f"Failed to send email notification: {e!s}", "Email Notification") - + if not linked_sites: + return -@frappe.whitelist() -def preview_apps_json(custom_image_name: str) -> dict[str, Any]: - """API endpoint to preview generated apps.json for a Custom Image. + new_status = self._map_build_status_to_site_status() + if not new_status: + return - Args: - custom_image_name: Name of the Custom Image document + for site in linked_sites: + if new_status != site.status: + frappe.db.set_value("Frappe Site", site.name, "status", new_status, update_modified=False) + frappe.logger().info( + f"Synced status for Frappe Site {site.name}: {site.status} → {new_status}" + ) - Returns: - Dict with apps_json content and base64 version - """ - try: - custom_image = frappe.get_cached_doc("Custom Image", custom_image_name) - apps_json = custom_image.generate_apps_json() - apps_json_base64 = custom_image.generate_apps_json_base64() + frappe.db.commit() # nosemgrep: frappe-semgrep-rules.rules.frappe-manual-commit - return { - "success": True, - "apps_json": apps_json, - "apps_json_base64": apps_json_base64, - "app_count": len(json.loads(apps_json)), + def _map_build_status_to_site_status(self): + mapping = { + "Draft": None, + "Building": "Building", + "Built": "Ready To Deploy", + "Failed": "Build Failed", } - except Exception as e: - return {"success": False, "error": str(e)} + return mapping.get(self.build_status) + @frappe.whitelist() + def enqueue_build_custom_image(self): + self._update_status("Building") -@frappe.whitelist() -def create_and_build_custom_image(server_name, apps, custom_apps, image_name, frappe_version): - try: - if isinstance(apps, str): - apps = json.loads(apps) - if isinstance(custom_apps, str): - custom_apps = json.loads(custom_apps) if custom_apps else [] - if custom_apps is None: - custom_apps = [] - - if not frappe.db.exists("Server", server_name): - frappe.throw(_("Server {0} not found").format(server_name)) - - server = frappe.get_doc("Server", server_name) - if server.verify_status != "Prepared": - frappe.throw(_("Server must be in 'Prepared' status before building custom images")) - - custom_image = frappe.get_doc( - { - "doctype": "Custom Image", - "server_name": server_name, - "image_name": image_name, - "frappe_version": frappe_version.lower(), - "build_status": "Draft", - } + frappe.enqueue_doc( + "Custom Image", + self.name, + "build_custom_image", + queue="long", + timeout=1200, + enqueue_after_commit=True, ) + return {"status": "queued", "message": _("Build process has been queued")} - for app_name in apps: - app_filters = [["scrubbed_name", "=", app_name.lower()]] - apps_docs = frappe.get_all("Apps", filters=app_filters, fields=["name"]) - - if apps_docs: - custom_image.append("apps_config", {"app_name": apps_docs[0].name}) - else: - frappe.log_error(f"App with scrubbed_name '{app_name}' not found in Apps doctype") - - for custom_app in custom_apps: - app_name = custom_app.get("name", "") - if not app_name: - continue + @frappe.whitelist() + def preview_apps_json_for_form(self): + try: + apps_json = self._generate_apps_json() + return { + "success": True, + "apps_json": apps_json, + "apps_json_base64": base64.b64encode(apps_json.encode()).decode(), + "app_count": len(json.loads(apps_json)), + } + except Exception as e: + return {"success": False, "error": str(e)} - if not frappe.db.exists("Apps", app_name): - apps_doc = frappe.get_doc( - { - "doctype": "Apps", - "name": app_name, - "scrubbed_name": app_name.lower(), - "repo_url": custom_app.get("githubUrl", ""), - "branch": custom_app.get("branch", "main"), - "personal_access_token": custom_app.get("token", ""), - "is_custom": 1, - "order": 999, - } - ) - apps_doc.insert(ignore_permissions=True) - custom_image.append("apps_config", {"app_name": app_name}) +@frappe.whitelist() +def create_and_build_custom_image(server_name, apps, custom_apps, image_name, frappe_version): + apps = frappe.parse_json(apps) if isinstance(apps, str) else apps + custom_apps = frappe.parse_json(custom_apps) if isinstance(custom_apps, str) else (custom_apps or []) - custom_image.insert(ignore_permissions=True) + if not frappe.db.exists("Server", server_name): + frappe.throw(_("Server '{0}' not found").format(server_name)) - result = custom_image.enqueue_build_custom_image() + server = frappe.get_doc("Server", server_name) + if server.verify_status != "Prepared": + frappe.throw( + _("Server '{0}' must be in 'Prepared' status before building custom images").format(server_name) + ) - return { - "status": "success", - "message": result.get("message", "Build process queued successfully"), - "custom_image_name": custom_image.name, + custom_image = frappe.new_doc("Custom Image") + custom_image.update( + { + "server_name": server_name, + "image_name": image_name, + "frappe_version": frappe_version, + "build_status": "Draft", } + ) - except Exception as e: - frappe.log_error(f"Error creating custom image: {e}") - return {"status": "error", "message": str(e)} + for app_name in apps: + app_doc_name = frappe.db.get_value("Apps", {"scrubbed_name": app_name.lower()}, "name") + if app_doc_name: + custom_image.append("apps_config", {"app_name": app_doc_name}) + for custom_app in custom_apps: + app_name = custom_app.get("name") + repo_url = custom_app.get("githubUrl") -@frappe.whitelist() -def get_build_status(custom_image_name): - """ - Get build status for a Custom Image. + if not app_name or not repo_url: + frappe.throw( + _("Custom app must have both 'name' and 'githubUrl' fields. Received: {0}").format(custom_app) + ) - Args: - custom_image_name: Name of the Custom Image doctype + if not frappe.db.exists("Apps", app_name): + frappe.get_doc( + { + "doctype": "Apps", + "app_name": app_name, + "repo_url": repo_url, + "branch": custom_app.get("branch") or "main", + "is_custom": 1, + "is_public": 1, + "enabled": 1, + "order": 999, + } + ).insert(ignore_permissions=True) - Returns: - dict: {status, build_duration, built_at, build_log} - """ - try: - if not frappe.db.exists("Custom Image", custom_image_name): - frappe.throw(_("Custom Image {0} not found").format(custom_image_name)) + custom_image.append("apps_config", {"app_name": app_name}) - custom_image = frappe.get_doc("Custom Image", custom_image_name) + custom_image.insert(ignore_permissions=True) + result = custom_image.enqueue_build_custom_image() - return { - "status": custom_image.build_status, - "build_duration": custom_image.build_duration or 0, - "built_at": custom_image.built_at, - "build_log": custom_image.build_log if hasattr(custom_image, "build_log") else "", - "image_tag": custom_image.image_tag if custom_image.build_status == "Built" else None, - } + return { + "status": "success", + "message": result.get("message"), + "custom_image_name": custom_image.name, + } - except Exception as e: - frappe.log_error(f"Error getting build status: {e}") - return {"status": "error", "message": str(e)} + +@frappe.whitelist() +def get_build_status(custom_image_name): + doc = frappe.get_doc("Custom Image", custom_image_name) + return { + "status": doc.build_status, + "build_duration": doc.build_duration or 0, + "built_at": doc.built_at, + "image_tag": doc.image_tag if doc.build_status == "Built" else None, + } diff --git a/nano_press/nano_press/doctype/frappe_site/frappe_site.json b/nano_press/nano_press/doctype/frappe_site/frappe_site.json index b29867d..b0084a6 100644 --- a/nano_press/nano_press/doctype/frappe_site/frappe_site.json +++ b/nano_press/nano_press/doctype/frappe_site/frappe_site.json @@ -57,7 +57,7 @@ "in_list_view": 1, "label": "Status", "no_copy": 1, - "options": "Not Deployed\nReady To Deploy\nDeploying\nDeployed\nFailed\nStopped", + "options": "Not Deployed\nBuilding\nBuild Failed\nReady To Deploy\nDeploying\nDeployed\nFailed\nStopped", "read_only": 1 }, { @@ -87,7 +87,6 @@ "in_preview": 1, "label": "Admin Password", "no_copy": 1, - "not_nullable": 1, "placeholder": "s0QlAPg8udcOvij", "read_only": 1 }, @@ -102,7 +101,7 @@ "fieldname": "custom_image", "fieldtype": "Link", "label": "Custom Image", - "link_filters": "[[\"Custom Image\",\"build_status\",\"=\",\"Built\"]]", + "link_filters": "[[\"Custom Image\",\"build_status\",\"in\",[\"Built\",\"Building\"]]]", "mandatory_depends_on": "eval:doc.is_custom=='1'", "options": "Custom Image" }, @@ -199,7 +198,7 @@ "link_fieldname": "bench_name" } ], - "modified": "2025-11-05 23:04:45.350674", + "modified": "2026-01-06 22:17:53.193125", "modified_by": "Administrator", "module": "Nano Press", "name": "Frappe Site", diff --git a/nano_press/nano_press/doctype/frappe_site/frappe_site.py b/nano_press/nano_press/doctype/frappe_site/frappe_site.py index 2c6ddaa..51b898f 100644 --- a/nano_press/nano_press/doctype/frappe_site/frappe_site.py +++ b/nano_press/nano_press/doctype/frappe_site/frappe_site.py @@ -4,6 +4,7 @@ import os import frappe +from frappe import _ from frappe.model.document import Document from frappe.utils import random_string @@ -53,6 +54,29 @@ def after_insert(self): self.flags.ignore_validate = True self.save(ignore_permissions=True) + self._check_custom_image_status() + + def _check_custom_image_status(self): + if not self.is_custom or not self.custom_image: + return + + custom_image = frappe.get_cached_doc("Custom Image", self.custom_image) + + if custom_image.build_status == "Building": + frappe.db.set_value("Frappe Site", self.name, "status", "Building", update_modified=False) + frappe.db.commit() # nosemgrep: frappe-semgrep-rules.rules.frappe-manual-commit + elif custom_image.build_status == "Built": + pass + elif custom_image.build_status == "Failed": + frappe.db.set_value("Frappe Site", self.name, "status", "Build Failed", update_modified=False) + frappe.db.commit() # nosemgrep: frappe-semgrep-rules.rules.frappe-manual-commit + elif custom_image.build_status == "Draft": + frappe.msgprint( + _("Custom Image has not been built yet. Please build it first."), + indicator="orange", + alert=True, + ) + def before_save(self): if self.docstatus == 1: return @@ -61,20 +85,34 @@ def before_save(self): def validate(self): self.validate_server() + self.validate_custom_image() if self.docstatus == 0: self._ensure_password() def validate_server(self): linked_server = (self.server_name or "").strip() if not linked_server: - frappe.throw("Please select a Server before deploying.") + frappe.throw(_("Please select a Server before deploying.")) if not frappe.db.exists("Server", linked_server): - frappe.throw(f"Linked Server '{linked_server}' does not exist.") + frappe.throw(_("Linked Server '{0}' does not exist.").format(linked_server)) server = frappe.get_cached_doc("Server", linked_server) if getattr(server, "verify_status", "Not Verified") != "Prepared": - frappe.throw("Server is not verified. Please verify the server first.") + frappe.throw(_("Server is not verified. Please verify the server first.")) return server + def validate_custom_image(self): + if not self.is_custom or not self.custom_image: + return + + custom_image = frappe.get_cached_doc("Custom Image", self.custom_image) + + if custom_image.build_status == "Failed": + frappe.throw( + _( + "Custom Image '{0}' build failed. Please rebuild the image or select a different one before deploying." + ).format(self.custom_image) + ) + def _ensure_password(self): if not self.admin_password: self.admin_password = random_string(10) @@ -139,6 +177,15 @@ def _generate_site_url(self, bench_name: str) -> str: @frappe.whitelist() def prepare_for_deployment(self) -> dict: self.validate_server() + + if self.is_custom and self.custom_image: + custom_image = frappe.get_cached_doc("Custom Image", self.custom_image) + if custom_image.build_status != "Built": + return { + "status": 400, + "message": f"Custom Image is not ready (status: {custom_image.build_status}). Please wait for build to complete.", + } + vars = self.get_deployment_vars() self.status = "Deploying" self.save() @@ -301,3 +348,19 @@ def deploy_site(site_name: str) -> dict: """Wrapper function to call deploy_site on a Frappe Site document""" doc = frappe.get_doc("Frappe Site", site_name) return doc.deploy_site() + + +@frappe.whitelist() +def rebuild_custom_image_for_site(site_name: str) -> dict: + if not frappe.db.exists("Frappe Site", site_name): + frappe.throw(f"Frappe Site {site_name} not found") + + site = frappe.get_doc("Frappe Site", site_name) + + if not site.is_custom or not site.custom_image: + return {"status": "error", "message": "Site does not use custom image"} + + custom_image = frappe.get_doc("Custom Image", site.custom_image) + result = custom_image.enqueue_build_custom_image() + + return {"status": "success", "message": result.get("message"), "custom_image_name": custom_image.name} diff --git a/nano_press/nano_press/doctype/server/server.py b/nano_press/nano_press/doctype/server/server.py index e4b59f3..d658734 100644 --- a/nano_press/nano_press/doctype/server/server.py +++ b/nano_press/nano_press/doctype/server/server.py @@ -5,6 +5,7 @@ import subprocess import frappe +from frappe import _ from frappe.model.document import Document from nano_press.utils.ansible_runner import run_playbook @@ -240,7 +241,7 @@ def prepare_server(server_name: str, include_traefik: bool = False): dict with status, message, versions, and log_id """ if not server_name: - frappe.throw("Server name is required") + frappe.throw(_("Server name is required")) server = frappe.get_doc("Server", server_name) return server.prepare_server(include_traefik=include_traefik) diff --git a/nano_press/nano_press/utils/ansible/playbooks/build_custom_image.yml b/nano_press/nano_press/utils/ansible/playbooks/build_custom_image.yml index e7b8876..795bd19 100644 --- a/nano_press/nano_press/utils/ansible/playbooks/build_custom_image.yml +++ b/nano_press/nano_press/utils/ansible/playbooks/build_custom_image.yml @@ -29,12 +29,16 @@ failed_when: docker_info.rc != 0 changed_when: false - - name: Clone or update frappe_docker repo + - name: Remove frappe_docker directory to ensure clean clone + ansible.builtin.file: + path: "{{ frappe_docker_dir }}" + state: absent + + - name: Clone frappe_docker repo (fresh) ansible.builtin.git: repo: "https://github.com/frappe/frappe_docker" dest: "{{ frappe_docker_dir }}" - update: yes - force: yes + clone: yes accept_hostkey: yes - name: Ensure apps_json_base64 is provided diff --git a/nano_press/nano_press/utils/ansible/src/AnsibleRunner.py b/nano_press/nano_press/utils/ansible/src/AnsibleRunner.py index 3b4ddf8..c471a9f 100644 --- a/nano_press/nano_press/utils/ansible/src/AnsibleRunner.py +++ b/nano_press/nano_press/utils/ansible/src/AnsibleRunner.py @@ -14,6 +14,7 @@ from typing import Any import frappe +from frappe import _ @dataclass(frozen=True) @@ -80,7 +81,7 @@ def run_playbook( private_key = private_key or resolved_key if not host or not user or port is None: - frappe.throw("Insufficient connection details: host/user/port are required.") + frappe.throw(_("Insufficient connection details: host/user/port are required.")) playbook_abs = self._resolve_playbook_path(playbook_path) @@ -140,7 +141,7 @@ def run_ping( private_key = private_key or resolved_key if not host or not user or port is None: - frappe.throw("Insufficient connection details: host/user/port are required.") + frappe.throw(_("Insufficient connection details: host/user/port are required.")) with self._temp_inventory(host, user, int(port)) as inv: cmd = [self.ansible_bin, "all", "-i", str(inv), "-m", "ping"] @@ -165,10 +166,6 @@ def run_ping( }, ) - # --------------------- - # Internal helpers - # --------------------- - def _run(self, cmd: list[str], timeout: int) -> tuple[int, str, str]: try: proc = subprocess.run( @@ -202,10 +199,6 @@ def _temp_vars(self, extra_vars: Mapping[str, Any] | None): path.write_text(json.dumps(extra_vars)) yield path - # --------------------- - # Structured JSON output - # --------------------- - def _to_structured_json( self, *, @@ -254,7 +247,7 @@ def _get_server_conn( Returns: (host, user, port, private_key_path_or_None) """ if not (server_ip or server_name): - frappe.throw("Pass either server_ip or server_name to resolve connection details.") + frappe.throw(_("Pass either server_ip or server_name to resolve connection details.")) filters = {"server_ip": server_ip} if server_ip else {"server_name": server_name} row = frappe.db.get_value( diff --git a/nano_press/nano_press/utils/remote_builder.py b/nano_press/nano_press/utils/remote_builder.py index 38e1240..1662e1d 100644 --- a/nano_press/nano_press/utils/remote_builder.py +++ b/nano_press/nano_press/utils/remote_builder.py @@ -8,6 +8,7 @@ from typing import Any import frappe +from frappe import _ from nano_press.utils.ansible_runner import run_playbook @@ -21,7 +22,7 @@ def __init__(self, custom_image_name: str): def build_image_on_server(self, server_name: str | None = None) -> str: if not server_name: if not self.custom_image_doc.server_name: - frappe.throw("No server linked to Custom Image and no server_name provided") + frappe.throw(_("No server linked to Custom Image and no server_name provided")) server_name = self.custom_image_doc.server_name server_doc = frappe.get_cached_doc("Server", server_name) @@ -47,16 +48,16 @@ def build_image_on_server(self, server_name: str | None = None) -> str: def _validate_custom_image(self) -> None: if not self.custom_image_doc.apps_config: - frappe.throw("No apps configured for this Custom Image") + frappe.throw(_("No apps configured for this Custom Image")) if not self.custom_image_doc.frappe_version: - frappe.throw("Frappe version not specified") + frappe.throw(_("Frappe version not specified")) if not self.custom_image_doc.image_name: - frappe.throw("Image name not specified") + frappe.throw(_("Image name not specified")) if not self.custom_image_doc.server_name: - frappe.throw("No server linked to this Custom Image") + frappe.throw(_("No server linked to this Custom Image")) def _validate_server(self, server_doc) -> None: if server_doc.verify_status != "Verified": diff --git a/nano_press/public/css/main.css b/nano_press/public/css/main.css index eb68725..cda60c1 100644 --- a/nano_press/public/css/main.css +++ b/nano_press/public/css/main.css @@ -6,6 +6,26 @@ @utility no-scrollbar { @apply [scrollbar-width:none] [&::-webkit-scrollbar]:hidden; } + +@utility animate-blink { + animation: blink 1.5s ease-in-out infinite; +} + +@keyframes blink { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } +} + +@media (prefers-reduced-motion: reduce) { + .animate-blink { + animation: none; + } +} + /* Embla carousel settings */ .embla__controls { display: flex; diff --git a/nano_press/public/css/theme.css b/nano_press/public/css/theme.css index 5fa0294..d6007d9 100644 --- a/nano_press/public/css/theme.css +++ b/nano_press/public/css/theme.css @@ -9,7 +9,32 @@ /* Colors */ - + /* Penguin UI Design Tokens */ + --color-primary: oklch(45.56% 0.237 264.05); + --color-primary-dark: oklch(70% 0.2 264.05); + + /* Surface colors */ + --color-surface: oklch(100% 0 0); + --color-surface-alt: oklch(98% 0 0); + --color-surface-dark: oklch(23% 0.011 285.82); + --color-surface-dark-alt: oklch(20% 0.011 285.82); + + /* Text colors */ + --color-on-surface: oklch(40% 0.019 285.88); + --color-on-surface-strong: oklch(20% 0.019 285.88); + --color-on-surface-dark: oklch(90% 0.005 285.82); + --color-on-surface-dark-strong: oklch(100% 0 0); + --color-on-primary: oklch(100% 0 0); + --color-on-primary-dark: oklch(20% 0.019 285.88); + + /* Border colors */ + --color-outline: oklch(85% 0.005 285.82); + --color-outline-dark: oklch(30% 0.011 285.82); + + /* Border radius */ + --radius: 0.5rem; + + /* font size */ --text-xxs: 10px; --text-3xs: 8px; diff --git a/nano_press/public/images/nano-press.png b/nano_press/public/images/nano-press.png new file mode 100644 index 0000000..b0a612a Binary files /dev/null and b/nano_press/public/images/nano-press.png differ diff --git a/nano_press/public/js/script.js b/nano_press/public/js/script.js index 8bd1631..535a55f 100644 --- a/nano_press/public/js/script.js +++ b/nano_press/public/js/script.js @@ -23,10 +23,12 @@ selectedApps: [], customApps: [], showCustomAppDialog: false, + appType: 'public', customAppForm: { name: '', githubUrl: '', token: '', + branch: 'main', }, domain: '', @@ -57,15 +59,20 @@ }, addCustomApp() { - if (this.customAppForm.name && this.customAppForm.githubUrl) { - this.customApps.push({ - name: this.customAppForm.name, - githubUrl: this.customAppForm.githubUrl, - token: this.customAppForm.token, - }); - this.customAppForm = { name: '', githubUrl: '', token: '' }; - this.showCustomAppDialog = false; - } + this.customApps.push({ + name: this.customAppForm.name, + githubUrl: this.customAppForm.githubUrl, + token: this.appType === 'private' ? this.customAppForm.token : '', + branch: this.customAppForm.branch || 'main', + }); + this.customAppForm = { + name: '', + githubUrl: '', + token: '', + branch: 'main', + }; + this.appType = 'public'; + this.showCustomAppDialog = false; }, removeCustomApp(index) { diff --git a/nano_press/public/js/vendor/alpinejs/plugins/mask.js b/nano_press/public/js/vendor/alpinejs/plugins/mask.js new file mode 100644 index 0000000..6b5caa5 --- /dev/null +++ b/nano_press/public/js/vendor/alpinejs/plugins/mask.js @@ -0,0 +1 @@ +(()=>{function b(n){n.directive("mask",(e,{value:t,expression:u},{effect:f,evaluateLater:a,cleanup:r})=>{let l=()=>u,o="";queueMicrotask(()=>{if(["function","dynamic"].includes(t)){let i=a(u);f(()=>{l=d=>{let g;return n.dontAutoEvaluateFunctions(()=>{i(c=>{g=typeof c=="function"?c(d):c},{scope:{$input:d,$money:w.bind({el:e})}})}),g},s(e,!1)})}else s(e,!1);if(e._x_model){if(e._x_model.get()===e.value||e._x_model.get()===null&&e.value==="")return;e._x_model.set(e.value)}});let p=new AbortController;r(()=>{p.abort()}),e.addEventListener("input",()=>s(e),{signal:p.signal,capture:!0}),e.addEventListener("blur",()=>s(e,!1),{signal:p.signal});function s(i,d=!0){let g=i.value,c=l(g);if(!c||c==="false")return!1;if(o.length-i.value.length===1)return o=i.value;let v=()=>{o=i.value=h(g,c)};d?k(i,c,()=>{v()}):v()}function h(i,d){if(i==="")return"";let g=m(d,i);return x(d,g)}}).before("model")}function k(n,e,t){let u=n.selectionStart,f=n.value;t();let a=f.slice(0,u),r=x(e,m(e,a)).length;n.setSelectionRange(r,r)}function m(n,e){let t=e,u="",f={9:/[0-9]/,a:/[a-zA-Z]/,"*":/[a-zA-Z0-9]/},a="";for(let r=0;rLeave empty to skip domain setup
- - -+ To add a custom domain, you must already own it. If you don't have one, skip this step and we'll provide you + with a free Traefik domain. +
+ +Leave empty to skip domain setup
+ +Point your domain to the server by creating an A record:
++ → +
+
- IP:
-
-
- ✓ Verified
+ Server IP Domain
- User:
-
-
- Port:
-
- Not configured
- Version:
-
-
- Apps:
-
-
-
- ,
-
-
-
- None
- Frappe Version Frappe Apps
+
+
+ ,
+
+
+ None
- Custom Apps:
-
+ Custom Apps
- ,
+ ,
-
-
- Domain:
-
-
- ✓ Verified
-
-
- ⚠ Not Verified
-
- No domain configured Your Frappe site has been successfully deployed! Login Credentials:
- Please Note: Deployment time may vary depending on the number of applications being installed.
+ Please Note: Deployment time may vary depending on the number of
+ applications being installed.
+ Built for developers who value simplicity, speed, and complete control over their infrastructure.
+
+ Deploy Frappe sites in minutes, not hours. Automated Docker-based deployments with zero manual configuration. From code to production in 5 minutes.
+
+ Your infrastructure, your rules. Deploy on any server with SSH access. No vendor lock-in, no hidden costs. Complete ownership of your data and deployment.
+
+ Built on Frappe, powered by BWH. Transparent codebase, no black boxes. Contribute, customize, and extend as you see fit. Freedom to innovate.
+ Deploy sites with custom app combinations. Mix and match ERPNext, HRMS, and your custom apps. Automatic SSL certificates with Let's Encrypt. Secure connections configured automatically via Traefik. Track deployments with live Ansible logs. Debug issues with complete visibility into the deployment process. Add custom domain without hassle. Seamless integration with DNS providers.
+ Deploy Frappe sites instantly with your own infrastructure.
+ Full control, complete transparency.
+ Deploy your Frappe applications in minutes Leave empty to skip domain setup Copy this key and add it to your server. Learn how IP: User: Port: Version: Apps: Domain: Your Frappe site is ready
+ Get started in minutes with your own infrastructure.
+ Verified
+
+ Frappe Apps
- Environment Details
+
+ Domain
-
- Deploying
Deploying
Deployment Complete
Deployment Complete
+ Why Choose Nano Press?
+
+ Lightning Fast
+ Full Control
+ 100% Open Source
+ Custom App Support
+ SSL Out of the Box
+ Real time Logs
+ Custom Domain
+
+ Introducing Nano Press
+
+
+ Self Host Frappe
- Domain
-
- Select Apps
-
- Server Details
-
- Summary
-
- Server
- Configuration
- Deploying
-
-
- Deployment Complete
- Ready to Deploy Your First Site?
+