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""" -

🚀 Custom Image Build Successful

-

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""" -

⚠️ Custom Image Build Failed

-

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;r{let s="",h=0;for(let i=o.length-1;i>=0;i--)o[i]!==p&&(h===3?(s=o[i]+p+s,h=0):s=o[i]+s,h++);return s},a=n.startsWith("-")?"-":"",r=n.replaceAll(new RegExp(`[^0-9\\${e}]`,"g"),""),l=Array.from({length:r.split(e)[0].length}).fill("9").join("");return l=`${a}${f(l,t)}`,u>0&&n.includes(e)&&(l+=`${e}`+"9".repeat(u)),queueMicrotask(()=>{this.el.value.endsWith(e)||this.el.value[this.el.selectionStart-1]===e&&this.el.setSelectionRange(this.el.selectionStart-1,this.el.selectionStart-1)}),l}document.addEventListener("alpine:init",()=>{window.Alpine.plugin(b)});})(); diff --git a/nano_press/templates/__base.html b/nano_press/templates/__base.html deleted file mode 100644 index 8dd0f3d..0000000 --- a/nano_press/templates/__base.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - {% block page_title %}{% endblock %} - {% block head %}{% endblock %} - - - {% block page_content %}{% endblock %} - {% block script %}{% endblock %} - - diff --git a/nano_press/templates/components/core/apps.html b/nano_press/templates/components/core/apps.html index 7ec1d1f..1e17405 100644 --- a/nano_press/templates/components/core/apps.html +++ b/nano_press/templates/components/core/apps.html @@ -4,105 +4,198 @@

Select Apps

-
-
-
-