# Copyright (c) 2019, JINGROW # For license information, please see license.txt from __future__ import annotations import json from typing import TYPE_CHECKING import jingrow import requests import wrapt from boto3 import client from botocore.exceptions import ClientError from frappe.core.utils import find from frappe.desk.doctype.tag.tag import add_tag from frappe.query_builder import Case from frappe.rate_limiter import rate_limit from frappe.utils import flt, sbool, time_diff_in_hours from frappe.utils.password import get_decrypted_password from frappe.utils.typing_validations import validate_argument_types from frappe.utils.user import is_system_user from press.access.support_access import has_support_access from press.guards import role_guard from press.press.doctype.agent_job.agent_job import job_detail from press.press.doctype.marketplace_app.marketplace_app import ( get_plans_for_app, get_total_installs_by_app, ) from press.press.doctype.remote_file.remote_file import get_remote_key from press.press.doctype.server.server import is_dedicated_server from press.press.doctype.site.site import Site, get_updates_between_current_and_next_apps from press.press.doctype.site_plan.plan import Plan from press.press.doctype.site_update.site_update import benches_with_available_update from press.utils import ( get_client_blacklisted_keys, get_current_team, get_frappe_backups, get_last_pg, log_error, unique, ) from press.utils.dns import check_dns_cname_a if TYPE_CHECKING: from press.press.doctype.bench.bench import Bench from press.press.doctype.database_server.database_server import DatabaseServer from press.press.doctype.deploy_candidate.deploy_candidate import DeployCandidate from press.press.doctype.release_group.release_group import ReleaseGroup from press.press.doctype.server.server import Server from press.press.doctype.team.team import Team def protected(doctypes): """ This decorator is stupid. It works in magical ways. It checks whether the owner of the Doctype (one of `doctypes`) is the same as the current team. The stupid magical part of this decorator is how it gets the name of the Doctype (see: `get_protected_doctype_name`); in order of precedence: 1. kwargs value with key `name` 2. first value in kwargs value with key `filters` i.e. ≈ `kwargs['filters'].values()[0]` 3. first value in the args tuple 4. kwargs value with key `snake_case(doctypes[0])` """ if not isinstance(doctypes, list): doctypes = [doctypes] @wrapt.decorator def wrapper(wrapped, instance, args, kwargs): user_type = frappe.session.data.user_type or frappe.get_cached_value( "User", frappe.session.user, "user_type" ) # System users have access to all endpoints. if user_type == "System User": return wrapped(*args, **kwargs) # Get the name of the document being accessed. if not (docname := get_protected_doctype_name(args, kwargs, doctypes)): frappe.throw("Name not found, API access not permitted", frappe.PermissionError) current_team = get_current_team() for doctype in doctypes: document_team = frappe.db.get_value(doctype, docname, "team") if document_team == current_team or has_support_access(doctype, docname): return wrapped(*args, **kwargs) frappe.throw("Not Permitted", frappe.PermissionError) return None return wrapper def get_protected_doctype_name(args: list, kwargs: dict, doctypes: list[str]): # 1. Name from kwargs["name"] or kwargs["pg_name"] if name := (kwargs.get("name") or kwargs.get("pg_name")): return name # 2. Name from first value in filters filters = kwargs.get("filters", {}) if name := get_name_from_filters(filters): return name # 3. Name from first value in args if len(args) >= 1 and args[0]: return args[0] if len(doctypes) == 0: return None # 4. Name from snakecased first `doctypes` name doctype = doctypes[0] key = doctype.lower().replace(" ", "_") return kwargs.get(key) def get_name_from_filters(filters: dict): values = [v for v in filters.values()] if len(values) == 0: return None value = values[0] if isinstance(value, int | str): return value return None def _new(site, server: str | None = None, ignore_plan_validation: bool = False): team = get_current_team(get_pg=True) if not team.enabled: frappe.throw("You cannot create a new site because your account is disabled") files = site.get("files", {}) apps = [{"app": app} for app in site["apps"]] group = get_group_for_new_site_and_set_localisation_app(site, apps) domain = site.get("domain") if not (domain and frappe.db.exists("Root Domain", {"name": domain})): frappe.throw("No root domain for site") cluster = site.get("cluster") or frappe.db.get_single_value("Press Settings", "cluster") Bench = frappe.qb.DocType("Bench") Server = frappe.qb.DocType("Server") ProxyServer = frappe.qb.DocType("Proxy Server") ProxyServerDomain = frappe.qb.DocType("Proxy Server Domain") proxy_servers = ( frappe.qb.from_(ProxyServer) .join(ProxyServerDomain) .on(ProxyServer.name == ProxyServerDomain.parent) .select(ProxyServer.name) .where(ProxyServerDomain.domain == domain) .where(ProxyServer.status == "Active") ).run(as_dict=True) proxy_servers = [d.name for d in proxy_servers] bench_query = ( frappe.qb.from_(Bench) .join(Server) .on(Bench.server == Server.name) .select(Bench.name, Bench.server) .where(Server.proxy_server.isin(proxy_servers)) .where(Bench.status == "Active") .where(Bench.group == site["group"]) .orderby(Case().when(Bench.cluster == cluster, 1).else_(0), order=frappe.qb.desc) .orderby(Server.use_for_new_sites, order=frappe.qb.desc) .orderby(Bench.creation, order=frappe.qb.desc) .limit(1) ) if server: bench_query = bench_query.where(Server.name == server) bench = bench_query.run(as_dict=True).pop() plan = site["plan"] app_plans = site.get("selected_app_plans") if not ignore_plan_validation: validate_plan(bench.server, plan) site = frappe.get_pg( { "doctype": "Site", "subdomain": site["name"], "domain": domain, "group": group, "server": server, "cluster": cluster, "apps": apps, "app_plans": app_plans, "team": team.name, "free": team.free_account, "subscription_plan": plan, "version": site.get("version"), "remote_config_file": files.get("config"), "remote_database_file": files.get("database"), "remote_public_file": files.get("public"), "remote_private_file": files.get("private"), "skip_failing_patches": site.get("skip_failing_patches", False), }, ) if app_plans and len(app_plans) > 0: subscription_docs = get_app_subscriptions(app_plans, team.name) # Set the secret keys for subscription in config secret_keys = {f"sk_{s.document_name}": s.secret_key for s in subscription_docs} site._update_configuration(secret_keys, save=False) site.insert(ignore_permissions=True) if app_plans and len(app_plans) > 0: # Set site in subscription docs for pg in subscription_docs: pg.site = site.name pg.save(ignore_permissions=True) return { "site": site.name, "job": frappe.db.get_value( "Agent Job", filters={ "site": site.name, "job_type": ("in", ["New Site", "New Site from Backup"]), }, ), } def get_group_for_new_site_and_set_localisation_app(site, apps): if not (localisation_country := site.get("localisation_country")): return site.get("group") # if localisation country is selected, move site to a public bench with the same localisation app localisation_app = frappe.db.get_value( "Marketplace Localisation App", {"country": localisation_country}, "marketplace_app" ) restricted_release_group_names = frappe.db.get_all( "Site Plan Release Group", pluck="release_group", filters={"parenttype": "Site Plan", "parentfield": "release_groups"}, ) ReleaseGroup = frappe.qb.DocType("Release Group") ReleaseGroupApp = frappe.qb.DocType("Release Group App") groups = ( frappe.qb.from_(ReleaseGroup) .select(ReleaseGroup.name) .join(ReleaseGroupApp) .on(ReleaseGroup.name == ReleaseGroupApp.parent) .where(ReleaseGroupApp.app == localisation_app) .where(ReleaseGroup.public == 1) .where(ReleaseGroup.enabled == 1) .where(ReleaseGroup.name.notin(restricted_release_group_names or [""])) .where(ReleaseGroup.version == site.get("version")) .run(pluck="name") ) if not groups: frappe.throw( f"Localisation app for {frappe.bold(localisation_country)} is not available for version {frappe.bold(site.get('version'))}" ) apps.append({"app": localisation_app}) return groups[0] @validate_argument_types def validate_plan(server: str, plan: str) -> None: if not frappe.db.exists("Site Plan", plan): frappe.throw(f"Plan {plan} does not exist", frappe.DoesNotExistError) if ( frappe.db.get_value("Site Plan", plan, "price_usd") > 0 or frappe.db.get_value("Site Plan", plan, "dedicated_server_plan") == 1 ): return if ( frappe.session.data.user_type == "System User" or frappe.db.get_value("Server", server, "team") == get_current_team() ): return frappe.throw("You are not allowed to use this plan") @jingrow.whitelist() def new(site): if not hasattr(site, "domain") and not site.get("domain"): site["domain"] = frappe.db.get_single_value("Press Settings", "domain") return _new(site) def get_app_subscriptions(app_plans, team_name: str): subscriptions = [] team: Team | None = None for app_name, plan_name in app_plans.items(): is_free = frappe.db.get_value("Marketplace App Plan", plan_name, "is_free") if not is_free: if not team: team = frappe.get_pg("Team", team_name) if not team.can_install_paid_apps(): frappe.throw( "You cannot install a Paid app on Free Credits. Please buy credits before trying to install again." ) new_subscription = frappe.get_pg( { "doctype": "Subscription", "document_type": "Marketplace App", "document_name": app_name, "plan_type": "Marketplace App Plan", "plan": plan_name, "enabled": 1, "team": team_name, } ).insert(ignore_permissions=True) subscriptions.append(new_subscription) return subscriptions @jingrow.whitelist() @protected("Site") def jobs(filters=None, order_by=None, limit_start=None, limit_page_length=None): jobs = frappe.get_all( "Agent Job", fields=["name", "job_type", "creation", "status", "start", "end", "duration"], filters=filters, start=limit_start, limit=limit_page_length, order_by=order_by or "creation desc", ) for job in jobs: job["status"] = "Pending" if job["status"] == "Undelivered" else job["status"] return jobs @jingrow.whitelist() def job(job): job = frappe.get_pg("Agent Job", job) job = job.as_dict() whitelisted_fields = [ "name", "job_type", "creation", "status", "start", "end", "duration", ] for key in list(job.keys()): if key not in whitelisted_fields: job.pop(key, None) if job.status == "Undelivered": job.status = "Pending" job.steps = frappe.get_all( "Agent Job Step", filters={"agent_job": job.name}, fields=["step_name", "status", "start", "end", "duration", "output"], order_by="creation", ) return job @jingrow.whitelist() @protected("Site") def running_jobs(name): jobs = frappe.get_all("Agent Job", filters={"status": ("in", ("Pending", "Running")), "site": name}) return [job_detail(job.name) for job in jobs] @jingrow.whitelist() @protected("Site") def backups(name): available_offsite_backups = frappe.db.get_single_value("Press Settings", "offsite_backups_count") or 30 fields = [ "name", "with_files", "database_file", "database_size", "database_url", "config_file_size", "config_file_url", "config_file", "private_file", "private_size", "private_url", "public_file", "public_size", "public_url", "creation", "status", "offsite", "remote_database_file", "remote_public_file", "remote_private_file", "remote_config_file", ] latest_backups = frappe.get_all( "Site Backup", fields=fields, filters={"site": name, "files_availability": "Available", "offsite": 0}, order_by="creation desc", limit=10, ) offsite_backups = frappe.get_all( "Site Backup", fields=fields, filters={"site": name, "files_availability": "Available", "offsite": 1}, order_by="creation desc", limit_page_length=available_offsite_backups, ) return sorted(latest_backups + offsite_backups, key=lambda x: x["creation"], reverse=True) @jingrow.whitelist() @protected("Site") def get_backup_link(name, backup, file): try: remote_file = frappe.db.get_value("Site Backup", backup, f"remote_{file}_file") return frappe.get_pg("Remote File", remote_file).download_link except ClientError: log_error(title="Offsite Backup Response Exception") @jingrow.whitelist() @protected("Site") def domains(name): domains = frappe.get_all( "Site Domain", fields=["name", "domain", "status", "retry_count", "redirect_to_primary"], filters={"site": name}, ) host_name = frappe.db.get_value("Site", name, "host_name") primary = find(domains, lambda x: x.domain == host_name) if primary: primary.primary = True domains.sort(key=lambda domain: not domain.primary) return domains @jingrow.whitelist() def activities(filters=None, order_by=None, limit_start=None, limit_page_length=None): # get all site activity except Backup by Administrator SiteActivity = frappe.qb.DocType("Site Activity") activities = ( frappe.qb.from_(SiteActivity) .select(SiteActivity.action, SiteActivity.reason, SiteActivity.creation, SiteActivity.owner) .where(SiteActivity.site == filters["site"]) .where((SiteActivity.action != "Backup") | (SiteActivity.owner != "Administrator")) .orderby(SiteActivity.creation, order=frappe.qb.desc) .offset(limit_start) .limit(limit_page_length) .run(as_dict=True) ) for activity in activities: if activity.action == "Create": activity.action = "Site Created" return activities @jingrow.whitelist() def app_details_for_new_public_site(): fields = [ "name", "title", "image", "description", "app", "route", "subscription_type", {"sources": ["source", "version"]}, {"localisation_apps": ["marketplace_app", "country"]}, ] marketplace_apps = frappe.qb.get_query( "Marketplace App", fields=fields, filters={"status": "Published", "show_for_site_creation": 1}, ).run(as_dict=True) marketplace_app_sources = [app["sources"][0]["source"] for app in marketplace_apps if app["sources"]] if not marketplace_app_sources: return [] AppSource = frappe.qb.DocType("App Source") MarketplaceApp = frappe.qb.DocType("Marketplace App") app_source_details = ( frappe.qb.from_(AppSource) .select( AppSource.name, AppSource.app, AppSource.repository_url, AppSource.repository, AppSource.repository_owner, AppSource.branch, AppSource.team, AppSource.public, MarketplaceApp.title.as_("app_title"), AppSource.frappe, ) .join(MarketplaceApp) .on(AppSource.app == MarketplaceApp.app) .where(AppSource.name.isin(marketplace_app_sources)) .run(as_dict=True) ) total_installs_by_app = get_total_installs_by_app() for app in marketplace_apps: app["plans"] = get_plans_for_app(app.app) app["total_installs"] = total_installs_by_app.get(app.app, 0) source_detail = find(app_source_details, lambda x: x.app == app.app) if source_detail: app.update({**source_detail}) return marketplace_apps @jingrow.whitelist() def options_for_new(for_bench: str | None = None): # noqa: C901 from press.utils import get_nearest_cluster available_versions = get_available_versions(for_bench) unique_app_sources = [] for version in available_versions: for app_source in version.group.bench_app_sources: if app_source not in unique_app_sources: unique_app_sources.append(app_source) if for_bench: app_source_details = frappe.db.get_all( "App Source", [ "name", "app", "repository_url", "repository", "repository_owner", "branch", "team", "public", "app_title", "frappe", ], filters={"name": ("in", unique_app_sources)}, ) unique_apps = [] app_source_details_grouped = {} for app_source in app_source_details: if app_source.app not in unique_apps: unique_apps.append(app_source.app) app_source_details_grouped[app_source.name] = app_source marketplace_apps = frappe.db.get_all( "Marketplace App", fields=["title", "image", "description", "app", "route", "subscription_type"], filters={"app": ("in", unique_apps)}, ) total_installs_by_app = get_total_installs_by_app() marketplace_details = {} for app in unique_apps: details = find(marketplace_apps, lambda x: x.app == app) if details: details["plans"] = get_plans_for_app(app) details["total_installs"] = total_installs_by_app.get(app, 0) marketplace_details[app] = details set_default_apps(app_source_details_grouped) else: app_source_details_grouped = app_details_for_new_public_site() # app source details are all fetched from marketplace apps for public sites marketplace_details = None default_domain = frappe.db.get_single_value("Press Settings", "domain") cluster_specific_root_domains = frappe.db.get_all( "Root Domain", {"name": ("like", f"%.{default_domain}")}, ["name", "default_cluster as cluster"], ) return { "versions": available_versions, "domain": default_domain, "closest_cluster": get_nearest_cluster(), "cluster_specific_root_domains": cluster_specific_root_domains, "marketplace_details": marketplace_details, "app_source_details": app_source_details_grouped, } def set_default_apps(app_source_details_grouped): press_settings = frappe.get_single("Press Settings") default_apps = press_settings.get_default_apps() for app_source in app_source_details_grouped.values(): if app_source["app"] in default_apps: app_source["preinstalled"] = True def get_available_versions(for_bench: str | None = None): available_versions = [] restricted_release_group_names = get_restricted_release_group_names() filters: dict[str, int | bool | tuple] = {} release_group_filters: dict[str, int | str | bool | tuple] = {} if for_bench: version = frappe.db.get_value("Release Group", for_bench, "version") filters = {"name": version} release_group_filters = {"name": for_bench} else: filters = {"public": True, "status": ("!=", "End of Life")} release_group_filters = { "public": 1, "enabled": 1, "saas_bench": 0, "name": ( "not in", restricted_release_group_names, ), # filter out restricted release groups } versions = frappe.db.get_all( "Frappe Version", ["name", "default", "status", "number"], filters, order_by="number desc", ) for version in versions: release_group_filters["version"] = version.name release_group = frappe.db.get_value( "Release Group", fieldname=["name", "`default`", "title", "public"], filters=release_group_filters, order_by="creation desc", as_dict=1, ) if release_group: version.group = release_group if for_bench: version.group.is_dedicated_server = is_dedicated_server( frappe.get_all( "Release Group Server", filters={"parent": release_group.name, "parenttype": "Release Group"}, pluck="server", limit=1, )[0] ) set_bench_and_clusters(version, for_bench) if version.group and version.group.bench and version.group.clusters: available_versions.append(version) return available_versions def get_restricted_release_group_names(): return frappe.db.get_all( "Site Plan Release Group", pluck="release_group", filters={"parenttype": "Site Plan", "parentfield": "release_groups"}, ) def set_bench_and_clusters(version, for_bench): # here we get the last created bench for the release group # assuming the last created bench is the latest one bench = frappe.db.get_value( "Bench", filters={"status": "Active", "group": version.group.name}, order_by="creation desc", ) if bench: version.group.bench = bench version.group.bench_app_sources = frappe.db.get_all( "Bench App", {"parent": bench, "app": ("!=", "frappe")}, pluck="source" ) cluster_names = unique( frappe.db.get_all( "Bench", filters={"candidate": frappe.db.get_value("Bench", bench, "candidate")}, pluck="cluster", ) ) clusters = frappe.db.get_all( "Cluster", filters={"name": ("in", cluster_names)}, fields=["name", "title", "image", "beta"], ) if not for_bench: proxy_servers = frappe.db.get_all( "Proxy Server", { "cluster": ("in", cluster_names), "is_primary": 1, }, ["name", "cluster"], ) for cluster in clusters: cluster.proxy_server = find(proxy_servers, lambda x: x.cluster == cluster.name) version.group.clusters = clusters @jingrow.whitelist() def get_domain(): return frappe.db.get_value("Press Settings", "Press Settings", ["domain"]) @jingrow.whitelist() def get_new_site_options(group: str | None = None): team = get_current_team() apps = set() filters: dict[str, bool | str] = {"enabled": True} versions_filters: dict[str, tuple | str | bool] = {"public": True} if group: # private bench filters.update({"name": group, "team": team}) else: filters.update({"public": True}) versions_filters.update({"status": ("!=", "End of Life")}) versions = frappe.get_all( "Frappe Version", ["name", "number", "default", "status"], filters=versions_filters, order_by="`default` desc, number desc", ) for version in versions: filters.update({"version": version.name}) rg = frappe.get_all( "Release Group", fields=["name", "`default`", "title"], filters=filters, limit=1, ) if not rg: continue rg = rg[0] benches = frappe.get_all( "Bench", filters={"status": "Active", "group": rg.name}, order_by="creation desc", limit=1, ) if not benches: continue bench_name = benches[0].name bench_apps = frappe.get_all("Bench App", {"parent": bench_name}, pluck="source") app_sources = frappe.get_all( "App Source", [ "name", "app", "repository_url", "repository", "repository_owner", "branch", "team", "public", "app_title", "frappe", ], filters={"name": ("in", bench_apps)}, or_filters={"public": True, "team": team}, ) rg["apps"] = sorted(app_sources, key=lambda x: bench_apps.index(x.name)) # Regions with latest update cluster_names = unique( frappe.db.get_all( "Bench", filters={"candidate": frappe.db.get_value("Bench", bench_name, "candidate")}, pluck="cluster", ) ) rg["clusters"] = frappe.db.get_all( "Cluster", filters={"name": ("in", cluster_names), "public": True}, fields=["name", "title", "image", "beta"], ) version["group"] = rg apps.update([source.app for source in app_sources]) marketplace_apps = frappe.db.get_all( "Marketplace App", fields=["title", "image", "description", "app", "route"], filters={"app": ("in", list(apps))}, ) return { "versions": versions, "marketplace_apps": {row.app: row for row in marketplace_apps}, } @jingrow.whitelist() def get_site_plans(): plans = Plan.get_plans( doctype="Site Plan", fields=[ "name", "plan_title", "price_usd", "price_inr", "cpu_time_per_day", "max_storage_usage", "max_database_usage", "database_access", "support_included", "offsite_backups", "private_benches", "monitor_access", "dedicated_server_plan", "is_trial_plan", "allow_downgrading_from_other_plan", ], # TODO: Remove later, temporary change because site plan has all document_type plans filters={"document_type": "Site"}, ) plan_names = [x.name for x in plans] if len(plan_names) == 0: return [] filtered_plans = [] SitePlan = frappe.qb.DocType("Site Plan") Bench = frappe.qb.DocType("Bench") ReleaseGroup = frappe.qb.DocType("Release Group") SitePlanReleaseGroup = frappe.qb.DocType("Site Plan Release Group") SitePlanAllowedApp = frappe.qb.DocType("Site Plan Allowed App") plan_details_query = ( frappe.qb.from_(SitePlan) .select(SitePlan.name, SitePlanReleaseGroup.release_group, SitePlanAllowedApp.app) .left_join(SitePlanReleaseGroup) .on(SitePlanReleaseGroup.parent == SitePlan.name) .left_join(SitePlanAllowedApp) .on(SitePlanAllowedApp.parent == SitePlan.name) .where(SitePlan.name.isin(plan_names)) ) plan_details_with_bench_query = ( frappe.qb.from_(plan_details_query) .select( plan_details_query.name, plan_details_query.release_group, plan_details_query.app, Bench.cluster, ReleaseGroup.version, ) .left_join(Bench) .on(Bench.group == plan_details_query.release_group) .left_join(ReleaseGroup) .on(ReleaseGroup.name == plan_details_query.release_group) .where(Bench.status == "Active") ) plan_details = plan_details_with_bench_query.run(as_dict=True) plan_details_dict = get_plan_details_dict(plan_details) for plan in plans: if plan.name in plan_details_dict: plan.clusters = plan_details_dict[plan.name]["clusters"] plan.allowed_apps = plan_details_dict[plan.name]["allowed_apps"] plan.bench_versions = plan_details_dict[plan.name]["bench_versions"] plan.restricted_plan = True else: plan.clusters = [] plan.allowed_apps = [] plan.bench_versions = [] plan.restricted_plan = False filtered_plans.append(plan) return filtered_plans def get_plan_details_dict(plan_details): plan_details_dict = {} for plan in plan_details: if plan["name"] not in plan_details_dict: plan_details_dict[plan["name"]] = { "allowed_apps": [], "release_groups": [], "clusters": [], "bench_versions": [], } if ( plan["release_group"] and plan["release_group"] not in plan_details_dict[plan["name"]]["release_groups"] ): plan_details_dict[plan["name"]]["release_groups"].append(plan["release_group"]) if plan["app"] and plan["app"] not in plan_details_dict[plan["name"]]["allowed_apps"]: plan_details_dict[plan["name"]]["allowed_apps"].append(plan["app"]) if plan["cluster"] and plan["cluster"] not in plan_details_dict[plan["name"]]["clusters"]: plan_details_dict[plan["name"]]["clusters"].append(plan["cluster"]) if plan["version"] and plan["version"] not in plan_details_dict[plan["name"]]["bench_versions"]: plan_details_dict[plan["name"]]["bench_versions"].append(plan["version"]) return plan_details_dict @jingrow.whitelist() def get_plans(name=None, rg=None): site_name = name plans = Plan.get_plans( doctype="Site Plan", fields=[ "name", "plan_title", "price_usd", "price_inr", "cpu_time_per_day", "max_storage_usage", "max_database_usage", "database_access", "support_included", "offsite_backups", "private_benches", "monitor_access", "dedicated_server_plan", "allow_downgrading_from_other_plan", ], # TODO: Remove later, temporary change because site plan has all document_type plans filters={"document_type": "Site"}, ) if site_name or rg: team = get_current_team() release_group_name = rg if rg else frappe.db.get_value("Site", site_name, "group") release_group = frappe.get_pg("Release Group", release_group_name) is_private_bench = release_group.team == team and not release_group.public is_system_user = frappe.db.get_value("User", frappe.session.user, "user_type") == "System User" # poor man's bench paywall # this will not allow creation of $10 sites on private benches # wanted to avoid adding a new field, so doing this with a date check :) # TODO: find a better way to do paywalls paywall_date = frappe.utils.get_datetime("2021-09-21 00:00:00") is_paywalled_bench = is_private_bench and release_group.creation > paywall_date and not is_system_user site_server = frappe.db.get_value("Site", site_name, "server") if site_name else None on_dedicated_server = is_dedicated_server(site_server) if site_server else None else: on_dedicated_server = None is_paywalled_bench = False out = [] for plan in plans: if is_paywalled_bench and plan.price_usd == 10: continue if not plan.allow_downgrading_from_other_plan and plan.price_usd == 5: continue if not on_dedicated_server and plan.dedicated_server_plan: continue if on_dedicated_server and not plan.dedicated_server_plan: continue out.append(plan) return out def sites_with_recent_activity(sites, limit=3): site_activity = frappe.qb.DocType("Site Activity") query = ( frappe.qb.from_(site_activity) .select(site_activity.site) .where(site_activity.site.isin(sites)) .where(site_activity.action != "Backup") .orderby(site_activity.creation, order=frappe.qb.desc) .limit(limit) .distinct() ) return query.run(pluck="site") @jingrow.whitelist() def all(site_filter=None): if site_filter is None: site_filter = {"status": "", "tag": ""} benches_with_updates = tuple(benches_with_available_update()) sites = get_sites_query(site_filter, benches_with_updates).run(as_dict=True) for site in sites: site.server_region_info = get_server_region_info(site) site_plan_name = frappe.get_value("Site", site.name, "plan") site.plan = frappe.get_pg("Site Plan", site_plan_name) if site_plan_name else None site.tags = frappe.get_all( "Resource Tag", {"parent": site.name}, pluck="tag_name", ) if site.bench in benches_with_updates: site.update_available = True return sites def get_sites_query(site_filter, benches_with_updates): Site = frappe.qb.DocType("Site") ReleaseGroup = frappe.qb.DocType("Release Group") from press.press.doctype.team.team import get_child_team_members team = get_current_team() child_teams = [x.name for x in get_child_team_members(team)] sites_query = ( frappe.qb.from_(Site) .select( Site.name, Site.host_name, Site.status, Site.creation, Site.bench, Site.current_cpu_usage, Site.current_database_usage, Site.current_disk_usage, Site.trial_end_date, Site.team, Site.cluster, Site.group, ReleaseGroup.title, ReleaseGroup.version, ReleaseGroup.public, ) .left_join(ReleaseGroup) .on(Site.group == ReleaseGroup.name) .orderby(Site.creation, order=frappe.qb.desc) ) if child_teams: sites_query = sites_query.where(Site.team.isin([team, *child_teams])) else: sites_query = sites_query.where(Site.team == team) if site_filter["status"] == "Active": sites_query = sites_query.where(Site.status == "Active") elif site_filter["status"] == "Broken": sites_query = sites_query.where(Site.status == "Broken") elif site_filter["status"] == "Inactive": sites_query = sites_query.where(Site.status == "Inactive") elif site_filter["status"] == "Trial": sites_query = sites_query.where((Site.trial_end_date != "") & (Site.status != "Archived")) elif site_filter["status"] == "Update Available": sites_query = sites_query.where(Site.bench.isin(benches_with_updates) & (Site.status != "Archived")) else: sites_query = sites_query.where(Site.status != "Archived") if site_filter["tag"]: Tag = frappe.qb.DocType("Resource Tag") sites_with_tag = frappe.qb.from_(Tag).select(Tag.parent).where(Tag.tag_name == site_filter["tag"]) sites_query = sites_query.where(Site.name.isin(sites_with_tag)) return sites_query @jingrow.whitelist() def site_tags(): team = get_current_team() return frappe.get_all("Press Tag", {"team": team, "doctype_name": "Site"}, pluck="tag") @jingrow.whitelist() @protected("Site") def get(name): from frappe.utils.data import time_diff team = get_current_team() try: site: "Site" = frappe.get_pg("Site", name) except frappe.DoesNotExistError: # If name is a custom domain then redirect to the site name site_name = frappe.db.get_value("Site Domain", name, "site") if site_name: frappe.local.response["type"] = "redirect" frappe.local.response["location"] = f"/api/method/press.api.site.get?name={site_name}" return None raise rg_info = frappe.db.get_value("Release Group", site.group, ["team", "version", "public"], as_dict=True) group_team = rg_info.team frappe_version = rg_info.version group_name = site.group if group_team == team or is_system_user(frappe.session.user) else None server = frappe.db.get_value( "Server", site.server, ["name", "ip", "is_standalone", "proxy_server", "team"], as_dict=True, ) if server.is_standalone: ip = server.ip else: ip = frappe.db.get_value("Proxy Server", server.proxy_server, "ip") site_migration = get_last_pg("Site Migration", {"site": site.name}) if ( site_migration and site_migration.status not in ["Failure", "Success"] and -1 <= time_diff(site_migration.scheduled_time, frappe.utils.now_datetime()).days <= 1 ): job = find(site_migration.steps, lambda x: x.status == "Running") site_migration = { "status": site_migration.status, "scheduled_time": site_migration.scheduled_time, "job_id": job.step_job if job else None, } else: site_migration = None version_upgrade = get_last_pg("Version Upgrade", {"site": site.name}) if ( version_upgrade and version_upgrade.status not in ["Failure", "Success"] and -1 <= time_diff(version_upgrade.scheduled_time, frappe.utils.now_datetime()).days <= 1 ): version_upgrade = { "status": version_upgrade.status, "scheduled_time": version_upgrade.scheduled_time, "job_id": frappe.get_value("Site Update", version_upgrade.site_update, "update_job"), } else: version_upgrade = None on_dedicated_server = is_dedicated_server(server.name) return { "name": site.name, "host_name": site.host_name, "status": site.status, "archive_failed": bool(site.archive_failed), "trial_end_date": site.trial_end_date, "setup_wizard_complete": site.setup_wizard_complete, "group": group_name, "team": site.team, "group_public": rg_info.public, "latest_frappe_version": frappe.db.get_value( "Frappe Version", {"status": "Stable", "public": True}, order_by="name desc" ), "frappe_version": frappe_version, "server": site.server, "server_region_info": get_server_region_info(site), "can_change_plan": server.team != team or (on_dedicated_server and server.team == team), "hide_config": site.hide_config, "communication_infos": [ {"channel": c.channel, "type": c.type, "value": c.value} for c in site.communication_infos ], "ip": ip, "site_tags": [{"name": x.tag, "tag": x.tag_name} for x in site.tags], "tags": frappe.get_all("Press Tag", {"team": team, "doctype_name": "Site"}, ["name", "tag"]), "info": { "owner": frappe.db.get_value( "User", frappe.get_cached_pg("Team", site.team).user, ["first_name", "last_name", "user_image"], as_dict=True, ), "created_on": site.creation, "last_deployed": ( frappe.db.get_all( "Site Activity", filters={"site": name, "action": "Update"}, order_by="creation desc", limit=1, pluck="creation", ) or [None] )[0], "auto_updates_enabled": not site.skip_auto_updates, }, "pending_for_long": site.pending_for_long, "site_migration": site_migration, "version_upgrade": version_upgrade, } @jingrow.whitelist() @protected("Site") def check_for_updates(name): site = frappe.get_pg("Site", name) out = frappe._dict() out.update_available = site.bench in benches_with_available_update(site=name) if not out.update_available: return out bench: "Bench" = frappe.get_pg("Bench", site.bench) source = bench.candidate destinations = frappe.get_all( "Deploy Candidate Difference", filters={"source": source}, limit=1, pluck="destination", ) if not destinations: out.update_available = False return out destination = destinations[0] destination_candidate: "DeployCandidate" = frappe.get_pg("Deploy Candidate", destination) out.installed_apps = site.apps current_apps = bench.apps next_apps = destination_candidate.apps out.apps = get_updates_between_current_and_next_apps( current_apps, next_apps, ) out.update_available = any([app["update_available"] for app in out.apps]) return out @jingrow.whitelist() @protected("Site") def installed_apps(name): site = frappe.get_cached_pg("Site", name) return get_installed_apps(site) def get_installed_apps(site, query_filters: dict | None = None): if query_filters is None: query_filters = {} installed_apps = [app.app for app in site.apps] bench = frappe.get_pg("Bench", site.bench) installed_bench_apps = [app for app in bench.apps if app.app in installed_apps] AppSource = frappe.qb.DocType("App Source") MarketplaceApp = frappe.qb.DocType("Marketplace App") query = ( frappe.qb.from_(AppSource) .left_join(MarketplaceApp) .on(AppSource.app == MarketplaceApp.app) .select( AppSource.name, AppSource.app, AppSource.repository, AppSource.repository_url, AppSource.repository_owner, AppSource.branch, AppSource.team, AppSource.public, AppSource.app_title, MarketplaceApp.title, MarketplaceApp.collect_feedback, ) .where(AppSource.name.isin([d.source for d in installed_bench_apps])) ) if owner := query_filters.get("repository_owner"): query = query.where(AppSource.repository_owner == owner) if branch := query_filters.get("branch"): query = query.where(AppSource.branch == branch) sources = query.run(as_dict=True) installed_apps = [] for app in installed_bench_apps: app_source = find(sources, lambda x: x.name == app.source) if not app_source: continue app_source.hash = app.hash app_source.commit_message = frappe.db.get_value("App Release", {"hash": app_source.hash}, "message") app_tags = frappe.db.get_value( "App Tag", { "repository": app_source.repository, "repository_owner": app_source.repository_owner, "hash": app_source.hash, }, ["tag", "timestamp"], as_dict=True, ) app_source.update(app_tags if app_tags else {}) app_source.subscription_available = bool( frappe.db.exists("Marketplace App Plan", {"price_usd": (">", 0), "app": app.app, "enabled": 1}) ) app_source.billing_type = is_prepaid_marketplace_app(app.app) if frappe.db.exists( "Subscription", { "site": site.name, "document_type": "Marketplace App", "document_name": app.app, "enabled": 1, }, ): subscription = frappe.get_value( "Subscription", { "site": site.name, "document_type": "Marketplace App", "document_name": app.app, "enabled": 1, }, ["document_name as app", "plan", "name"], as_dict=True, ) app_source.subscription = subscription app_source.plan_info = frappe.db.get_value( "Marketplace App Plan", subscription.plan, ["price_usd", "price_inr", "name", "plan"], as_dict=True, ) app_source.plans = get_plans_for_app(app.app) app_source.is_free = app_source.plan_info.price_usd <= 0 else: app_source.subscription = {} installed_apps.append(app_source) return installed_apps def get_server_region_info(site) -> dict: """Return a Dict with `title` and `image`""" return frappe.db.get_value("Cluster", site.cluster, ["title", "image"], as_dict=True) @jingrow.whitelist() @protected("Site") def available_apps(name): site = frappe.get_pg("Site", name) installed_apps = [app.app for app in site.apps] bench = frappe.get_pg("Bench", site.bench) bench_sources = [app.source for app in bench.apps] available_sources = [] AppSource = frappe.qb.DocType("App Source") MarketplaceApp = frappe.qb.DocType("Marketplace App") sources = ( frappe.qb.from_(AppSource) .left_join(MarketplaceApp) .on(AppSource.app == MarketplaceApp.app) .select( AppSource.name, AppSource.app, AppSource.repository_url, AppSource.repository_owner, AppSource.branch, AppSource.team, AppSource.public, AppSource.app_title, MarketplaceApp.title, ) .where(AppSource.name.isin(bench_sources)) .run(as_dict=True) ) for source in sources: frappe_version = frappe.db.get_value("Release Group", bench.group, "version") if is_marketplace_app_source(source.name): app_plans = get_plans_for_app(source.app, frappe_version) source.billing_type = is_prepaid_marketplace_app(source.app) else: app_plans = [] if len(app_plans) > 0: source.has_plans_available = True source.plans = app_plans if source.app not in installed_apps: available_sources.append(source) return sorted(available_sources, key=lambda x: bench_sources.index(x.name)) def is_marketplace_app_source(app_source_name): return frappe.db.exists("Marketplace App Version", {"source": app_source_name}) def is_prepaid_marketplace_app(app): return ( frappe.db.get_value("Saas Settings", app, "billing_type") if frappe.db.exists("Saas Settings", app) else "postpaid" ) @jingrow.whitelist() @protected("Site") def current_plan(name): from press.api.analytics import get_current_cpu_usage site = frappe.get_pg("Site", name) plan = frappe.get_pg("Site Plan", site.plan) if site.plan else None result = get_current_cpu_usage(name) total_cpu_usage_hours = flt(result / (3.6 * (10**9)), 5) usage = frappe.get_all( "Site Usage", fields=["database", "public", "private"], filters={"site": name}, order_by="creation desc", limit=1, ) if usage: usage = usage[0] total_database_usage = usage.database total_storage_usage = usage.public + usage.private else: total_database_usage = 0 total_storage_usage = 0 # number of hours until cpu usage resets now = frappe.utils.now_datetime() today_end = now.replace(hour=23, minute=59, second=59) hours_left_today = flt(time_diff_in_hours(today_end, now), 2) return { "current_plan": plan, "total_cpu_usage_hours": total_cpu_usage_hours, "hours_until_reset": hours_left_today, "max_database_usage": plan.max_database_usage if plan else None, "max_storage_usage": plan.max_storage_usage if plan else None, "total_database_usage": total_database_usage, "total_storage_usage": total_storage_usage, "database_access": plan.database_access if plan else None, "monitor_access": (is_system_user(frappe.session.user) or (plan.monitor_access if plan else None)), "usage_in_percent": { "cpu": site.current_cpu_usage, "disk": site.current_disk_usage, "database": site.current_database_usage, }, } @jingrow.whitelist() @protected("Site") def change_plan(name, plan): frappe.get_pg("Site", name).set_plan(plan) @jingrow.whitelist() @protected("Site") def change_auto_update(name, auto_update_enabled): # Not so good, it should have been "enable_auto_updates" # TODO: Make just one checkbox to track auto updates return frappe.db.set_value("Site", name, "skip_auto_updates", not auto_update_enabled) @jingrow.whitelist() @protected("Site") def deactivate(name): frappe.get_pg("Site", name).deactivate() @jingrow.whitelist() @protected("Site") def activate(name): frappe.get_pg("Site", name).activate() @jingrow.whitelist() @protected("Site") def login(name, reason=None): return {"sid": frappe.get_pg("Site", name).login(reason), "site": name} @jingrow.whitelist() @protected("Site") def update(name, skip_failing_patches=False, skip_backups=False): return frappe.get_pg("Site", name).schedule_update( skip_failing_patches=skip_failing_patches, skip_backups=skip_backups ) @jingrow.whitelist() @protected("Site") def last_migrate_failed(name): return frappe.get_pg("Site", name).last_migrate_failed() @jingrow.whitelist() @protected("Site") def backup(name, with_files=False): frappe.get_pg("Site", name).backup(with_files) @jingrow.whitelist() @protected("Site") def archive(name, force): frappe.get_pg("Site", name).archive(force=force) @jingrow.whitelist() @protected("Site") def reinstall(name): return frappe.get_pg("Site", name).reinstall() @jingrow.whitelist() @protected("Site") def migrate(name, skip_failing_patches=False): frappe.get_pg("Site", name).migrate(skip_failing_patches=skip_failing_patches) @jingrow.whitelist() @protected("Site") def clear_cache(name): frappe.get_pg("Site", name).clear_site_cache() @jingrow.whitelist() @protected("Site") def restore(name, files, skip_failing_patches=False): if not files.get("database") and not files.get("public") and not files.get("private"): frappe.throw("At least one file must be provided for restoration.") frappe.db.set_value( "Site", name, { "remote_database_file": files.get("database", ""), "remote_public_file": files.get("public", ""), "remote_private_file": files.get("private", ""), "remote_config_file": files.get("config", ""), }, ) site: Site = frappe.get_pg("Site", name) return site.restore_site(skip_failing_patches=skip_failing_patches) @jingrow.whitelist() @protected("Site") def validate_restoration_space_requirements( name: str, db_file_size: int, public_file_size: int, private_file_size: int ): site: Site = frappe.get_cached_pg("Site", name) server: Server = frappe.get_cached_pg("Server", site.server) database_server: DatabaseServer = frappe.get_cached_pg("Database Server", server.database_server) required_space_on_app_server = site.get_restore_space_required_on_app( db_file_size=db_file_size, public_file_size=public_file_size, private_file_size=private_file_size ) required_space_on_db_server = site.get_restore_space_required_on_db(db_file_size=db_file_size) free_space_on_app_server = server.free_space(server.guess_data_disk_mountpoint()) free_space_on_db_server = database_server.free_space(database_server.guess_data_disk_mountpoint()) allowed_to_upload = False if server.public: """ If it's a public server, Frappe Cloud will auto extend the disk space to accommodate the restoration. """ allowed_to_upload = True else: if ( free_space_on_app_server >= required_space_on_app_server and free_space_on_db_server >= required_space_on_db_server ): allowed_to_upload = True return { "allowed_to_upload": allowed_to_upload, "free_space_on_app_server": free_space_on_app_server if not server.public else -1, # -1 indicates unlimited space, no need to expose public server space "free_space_on_db_server": free_space_on_db_server if not database_server.public else -1, "is_insufficient_space_on_app_server": free_space_on_app_server < required_space_on_app_server, "is_insufficient_space_on_db_server": free_space_on_db_server < required_space_on_db_server, "required_space_on_app_server": required_space_on_app_server, "required_space_on_db_server": required_space_on_db_server, } @jingrow.whitelist(allow_guest=True) @rate_limit(limit=10, seconds=60) def exists(subdomain, domain): from press.press.doctype.site.site import Site return Site.exists(subdomain, domain) @jingrow.whitelist() @protected("Site") def setup_wizard_complete(name): return frappe.get_pg("Site", name).is_setup_wizard_complete() @jingrow.whitelist() @protected("Site") def check_dns(name, domain): return check_dns_cname_a(name, domain) @jingrow.whitelist() def domain_exists(domain): return frappe.db.get_value("Site Domain", domain.lower(), "site") @jingrow.whitelist() @protected("Site") def add_domain(name, domain): frappe.get_pg("Site", name).add_domain(domain) @jingrow.whitelist() @protected("Site") def remove_domain(name, domain): frappe.get_pg("Site", name).remove_domain(domain) @jingrow.whitelist() @protected("Site") def retry_add_domain(name, domain): frappe.get_pg("Site", name).retry_add_domain(domain) @jingrow.whitelist() @protected("Site") def set_host_name(name, domain): frappe.get_pg("Site", name).set_host_name(domain) @jingrow.whitelist() @protected("Site") def set_redirect(name, domain): frappe.get_pg("Site", name).set_redirect(domain) @jingrow.whitelist() @protected("Site") def unset_redirect(name, domain): frappe.get_pg("Site", name).unset_redirect(domain) @jingrow.whitelist() @protected("Site") def install_app(name, app, plan=None): frappe.get_pg("Site", name).install_app(app, plan) @jingrow.whitelist() @protected("Site") def uninstall_app(name, app): frappe.get_pg("Site", name).uninstall_app(app) @jingrow.whitelist() @protected("Site") def logs(name): return frappe.get_pg("Site", name).server_logs @jingrow.whitelist() @protected("Site") def log(name, log): return frappe.get_pg("Site", name).get_server_log(log) @jingrow.whitelist() @protected("Site") def site_config(name): site = frappe.get_pg("Site", name) config = list(filter(lambda x: not x.internal, site.configuration)) secret_keys = frappe.get_all("Site Config Key", filters={"type": "Password"}, pluck="key") for c in config: if c.key in secret_keys: c.type = "Password" c.value = "*******" return config @jingrow.whitelist() @protected("Site") def update_config(name, config): config = frappe.parse_json(config) config = [frappe._dict(c) for c in config] sanitized_config = [] for c in config: if c.key in get_client_blacklisted_keys(): continue if frappe.db.exists("Site Config Key", c.key): c.type = frappe.db.get_value("Site Config Key", c.key, "type") if c.type == "Number": c.value = flt(c.value) elif c.type == "Boolean": c.value = bool(sbool(c.value)) elif c.type == "JSON": c.value = frappe.parse_json(c.value) elif c.type == "Password" and c.value == "*******": c.value = frappe.get_value("Site Config", {"key": c.key, "parent": name}, "value") sanitized_config.append(c) site = frappe.get_pg("Site", name) site.update_site_config(sanitized_config) return list(filter(lambda x: not x.internal, site.configuration)) @jingrow.whitelist() def get_trial_plan(): return frappe.db.get_value("Press Settings", None, "press_trial_plan") @jingrow.whitelist() def get_upload_link(file, parts=1): bucket_name = frappe.db.get_single_value("Press Settings", "remote_uploads_bucket") expiration = frappe.db.get_single_value("Press Settings", "remote_link_expiry") or 3600 object_name = get_remote_key(file) parts = int(parts) s3_client = client( "s3", aws_access_key_id=frappe.db.get_single_value("Press Settings", "remote_access_key_id"), aws_secret_access_key=get_decrypted_password( "Press Settings", "Press Settings", "remote_secret_access_key" ), region_name="ap-south-1", ) try: # The response contains the presigned URL and required fields if parts > 1: signed_urls = [] response = s3_client.create_multipart_upload(Bucket=bucket_name, Key=object_name) for count in range(parts): signed_url = s3_client.generate_presigned_url( ClientMethod="upload_part", Params={ "Bucket": bucket_name, "Key": object_name, "UploadId": response.get("UploadId"), "PartNumber": count + 1, }, ) signed_urls.append(signed_url) payload = response payload["signed_urls"] = signed_urls return payload return s3_client.generate_presigned_post(bucket_name, object_name, ExpiresIn=expiration) except ClientError as e: log_error("Failed to Generate Presigned URL", content=e) @jingrow.whitelist() def multipart_exit(file, id, action, parts=None): bucket_name = frappe.db.get_single_value("Press Settings", "remote_uploads_bucket") s3_client = client( "s3", aws_access_key_id=frappe.db.get_single_value("Press Settings", "remote_access_key_id"), aws_secret_access_key=get_decrypted_password( "Press Settings", "Press Settings", "remote_secret_access_key", raise_exception=False, ), region_name="ap-south-1", ) if action == "abort": response = s3_client.abort_multipart_upload(Bucket=bucket_name, Key=file, UploadId=id) elif action == "complete": parts = json.loads(parts) # After completing for all parts, you will use complete_multipart_upload api which requires that parts list response = s3_client.complete_multipart_upload( Bucket=bucket_name, Key=file, UploadId=id, MultipartUpload={"Parts": parts}, ) return response @jingrow.whitelist() def uploaded_backup_info(file=None, path=None, type=None, size=None, url=None): pg = frappe.get_pg( { "doctype": "Remote File", "file_name": file, "file_type": type, "file_size": size, "file_path": path, "url": url, "bucket": frappe.db.get_single_value("Press Settings", "remote_uploads_bucket"), } ).insert() add_tag("Site Upload", pg.doctype, pg.name) return pg.name @jingrow.whitelist() def get_backup_links(url, email, password): try: files = get_frappe_backups(url, email, password) except requests.RequestException as e: frappe.throw(f"Could not fetch backups from {url}. Error: {e}") remote_files = [] for file_type, file_url in files.items(): file_name = file_url.split("backups/")[1].split("?sid=")[0] remote_files.append( { "type": file_type, "remote_file": uploaded_backup_info(file=file_name, url=file_url, type=file_type), "file_name": file_name, "url": file_url, } ) return remote_files @jingrow.whitelist() @protected("Site") def enable_auto_update(name): site_pg = frappe.get_pg("Site", name) if not site_pg.auto_updates_scheduled: site_pg.auto_updates_scheduled = True site_pg.save() @jingrow.whitelist() @protected("Site") def disable_auto_update(name): site_pg = frappe.get_pg("Site", name) if site_pg.auto_updates_scheduled: site_pg.auto_updates_scheduled = False site_pg.save() @jingrow.whitelist() @protected("Site") def get_auto_update_info(name): return frappe.get_pg("Site", name).get_auto_update_info() @jingrow.whitelist() @protected("Site") def update_auto_update_info(name, info=None): site_pg = frappe.get_pg("Site", name, for_update=True) site_pg.update(info or {}) site_pg.save() @jingrow.whitelist() def get_job_status(job_name): return {"status": frappe.db.get_value("Agent Job", job_name, "status")} @jingrow.whitelist() @protected("Site") def send_change_team_request(name, team_mail_id, reason): frappe.get_pg("Site", name).send_change_team_request(team_mail_id, reason) @jingrow.whitelist(allow_guest=True) def confirm_site_transfer(key: str): from frappe import _ if frappe.session.user == "Guest": return frappe.respond_as_web_page( _("Not Permitted"), _("You need to be logged in to confirm the site transfer."), http_status_code=403, indicator_color="red", primary_action="/dashboard/login", primary_label=_("Login"), ) if not isinstance(key, str): return frappe.respond_as_web_page( _("Not Permitted"), _("The link you are using is invalid."), http_status_code=403, indicator_color="red", ) if team_change := frappe.db.get_value("Team Change", {"key": key}): team_change = frappe.get_pg("Team Change", team_change) to_team = team_change.to_team if not frappe.db.get_value( "Team Member", {"user": frappe.session.user, "parent": to_team, "parenttype": "Team"} ): return frappe.respond_as_web_page( _("Not Permitted"), _("You are not a member of the team to which the site is being transferred."), http_status_code=403, indicator_color="red", ) team_change.transfer_completed = True team_change.save() frappe.db.commit() frappe.response.type = "redirect" frappe.response.location = f"/dashboard/sites/{team_change.document_name}" return None return frappe.respond_as_web_page( _("Not Permitted"), _("The link you are using is invalid or expired."), http_status_code=403, indicator_color="red", ) @jingrow.whitelist() @protected("Site") def add_server_to_release_group(name, group_name, server=None): if not server: server = frappe.db.get_value("Site", name, "server") rg: ReleaseGroup = frappe.get_pg("Release Group", group_name) if not frappe.db.exists("Deploy Candidate Build", {"status": "Success", "group": group_name}): frappe.throw( f"There should be atleast one deploy in the bench {frappe.bold(rg.title)} to do a site migration or a site version upgrade." ) try: deploy = rg.add_server(server, deploy=True) except PermissionError as e: if f"does not have access to this document: Release Group - {group_name}" in str(e): frappe.throw( f"Bench group is owned by a team you ({frappe.session.user}) are not a member of. Please contact the team owner or transfer the bench group to your team.", ) else: frappe.throw(str(e), type(e)) if isinstance(deploy, str): return None bench = find(deploy.benches, lambda bench: bench.server == server).bench return frappe.get_value("Agent Job", {"bench": bench, "job_type": "New Bench"}, "name") @jingrow.whitelist() def validate_group_for_upgrade(name, group_name): server = frappe.db.get_value("Site", name, "server") rg = frappe.get_pg("Release Group", group_name) if server not in [server.server for server in rg.servers]: return False return True @jingrow.whitelist() @protected("Site") @role_guard.document( document_type=lambda _: "Release Group", inject_values=True, should_throw=False, ) def change_group_options(name, release_groups=None): team = get_current_team() group, server, plan = frappe.db.get_value("Site", name, ["group", "server", "plan"]) if plan and not frappe.db.get_value("Site Plan", plan, "private_benches"): frappe.throw( "The current plan doesn't allow the site to be in a private bench. Please upgrade to a higher plan to move your site." ) version = frappe.db.get_value("Release Group", group, "version") Bench = frappe.qb.DocType("Bench") ReleaseGroup = frappe.qb.DocType("Release Group") query = ( frappe.qb.from_(Bench) .select(Bench.group.as_("name"), ReleaseGroup.title) .inner_join(ReleaseGroup) .on(ReleaseGroup.name == Bench.group) .where(Bench.status == "Active") .where(ReleaseGroup.name != group) .where(ReleaseGroup.version == version) .where(ReleaseGroup.team == team) .where(Bench.server == server) .groupby(Bench.group) ) if release_groups and isinstance(release_groups, list): query = query.where(ReleaseGroup.name.isin(release_groups)) return query.run(as_dict=True) @jingrow.whitelist() @protected("Site") def clone_group(name: str, new_group_title: str, server: str | None = None): site = frappe.get_pg("Site", name) group = frappe.get_pg("Release Group", site.group) cloned_group = frappe.new_pg("Release Group") cloned_group.update( { "title": new_group_title, "team": get_current_team(), "public": 0, "enabled": 1, "version": group.version, "dependencies": group.dependencies, "is_redisearch_enabled": group.is_redisearch_enabled, "servers": [{"server": server if server else site.server, "default": False}], } ) # add apps to rg if they are installed in site apps_installed_in_site = [app.app for app in site.apps] cloned_group.apps = [app for app in group.apps if app.app in apps_installed_in_site] cloned_group.insert() candidate = cloned_group.create_deploy_candidate() candidate.schedule_build_and_deploy() return { "bench_name": cloned_group.name, "candidate_name": candidate.name, } @jingrow.whitelist() @protected("Site") def change_group(name, group, skip_failing_patches=False): team = frappe.db.get_value("Release Group", group, "team") if team != get_current_team(): frappe.throw(f"Bench {group} does not belong to your team") site = frappe.get_pg("Site", name) site.move_to_group(group, skip_failing_patches=skip_failing_patches) @jingrow.whitelist() @protected("Site") def change_region_options(name): group, cluster = frappe.db.get_value("Site", name, ["group", "cluster"]) group = frappe.get_pg("Release Group", group) cluster_names = group.get_clusters() group_regions = frappe.get_all( "Cluster", filters={"name": ("in", cluster_names)}, fields=["name", "title", "image"] ) return { "regions": [region for region in group_regions if region.name != cluster], "current_region": cluster, } @jingrow.whitelist() @protected("Site") def change_region(name, cluster, scheduled_datetime=None, skip_failing_patches=False): group = frappe.db.get_value("Site", name, "group") bench_vals = frappe.db.get_value( "Bench", {"group": group, "cluster": cluster, "status": "Active"}, ["name", "server"] ) if bench_vals is None: frappe.throw(f"Bench {group} does not have an existing deploy in {cluster}") bench, server = bench_vals site_migration = frappe.get_pg( { "doctype": "Site Migration", "site": name, "destination_group": group, "destination_bench": bench, "destination_server": server, "destination_cluster": cluster, "scheduled_time": scheduled_datetime, "skip_failing_patches": skip_failing_patches, } ).insert() if not scheduled_datetime: site_migration.start() @jingrow.whitelist() @protected("Site") @role_guard.document( document_type=lambda _: "Release Group", inject_values=True, should_throw=False, ) def get_private_groups_for_upgrade(name, version, release_groups=None): team = get_current_team() version_number = frappe.db.get_value("Frappe Version", version, "number") next_version = frappe.db.get_value( "Frappe Version", { "number": version_number + 1, "status": ("in", ("Stable", "End of Life")), "public": True, }, "name", ) ReleaseGroup = frappe.qb.DocType("Release Group") ReleaseGroupServer = frappe.qb.DocType("Release Group Server") query = ( frappe.qb.from_(ReleaseGroup) .select(ReleaseGroup.name, ReleaseGroup.title) .join(ReleaseGroupServer) .on(ReleaseGroupServer.parent == ReleaseGroup.name) .where(ReleaseGroup.enabled == 1) .where(ReleaseGroup.team == team) .where(ReleaseGroup.public == 0) .where(ReleaseGroup.version == next_version) .distinct() ) if release_groups and isinstance(release_groups, list): query = query.where(ReleaseGroup.name.isin(release_groups)) return query.run(as_dict=True) @jingrow.whitelist() @protected("Site") def version_upgrade( name, destination_group, scheduled_datetime=None, skip_failing_patches=False, skip_backups=False ): site = frappe.get_pg("Site", name) current_version, shared_site, central_site = frappe.db.get_value( "Release Group", site.group, ["version", "public", "central_bench"] ) next_version = f"Version {int(current_version.split(' ')[1]) + 1}" if shared_site or central_site: ReleaseGroup = frappe.qb.DocType("Release Group") ReleaseGroupServer = frappe.qb.DocType("Release Group Server") destination_group = ( frappe.qb.from_(ReleaseGroup) .select(ReleaseGroup.name) .join(ReleaseGroupServer) .on(ReleaseGroupServer.parent == ReleaseGroup.name) .where(ReleaseGroup.version == next_version) .where(ReleaseGroup.public == shared_site) .where(ReleaseGroup.central_bench == central_site) .where(ReleaseGroup.enabled == 1) .where(ReleaseGroupServer.server == site.server) .run(as_dict=True, pluck="name") ) if destination_group: destination_group = destination_group[0] else: frappe.throw(f"There are no public benches with the version {frappe.bold(next_version)}.") version_upgrade = frappe.get_pg( { "doctype": "Version Upgrade", "site": name, "destination_group": destination_group, "scheduled_time": scheduled_datetime, "skip_failing_patches": skip_failing_patches, "skip_backups": skip_backups, } ).insert() if not scheduled_datetime: version_upgrade.start() @jingrow.whitelist() @protected("Site") def change_server_options(name): site = Site("Site", name) return { "servers": frappe.db.get_all( "Server", {"team": get_current_team(), "status": "Active", "name": ("!=", site.server)}, ["name", "title"], ), "estimated_duration": site.get_estimated_duration_for_server_change(), } @jingrow.whitelist() @protected("Site") def is_server_added_in_group(name, server): site_group = frappe.get_value("Site", name, "group") rg = frappe.get_pg("Release Group", site_group) if server not in [s.server for s in rg.servers]: return False return True @jingrow.whitelist() @protected("Site") def change_server(name, server, scheduled_datetime=None, skip_failing_patches=False): group = frappe.db.get_value("Site", name, "group") bench = frappe.db.get_value("Bench", {"group": group, "status": "Active", "server": server}, "name") if not bench: if frappe.db.exists( "Agent Job", { "job_type": "New Bench", "status": ("in", ("Pending", "Running")), "server": server, }, ): frappe.throw( f"Please wait for the new deploy to be created in the server {frappe.bold(server)} if you have just added a new server to the bench." ) else: frappe.throw( f"A deploy does not exist in the server {frappe.bold(server)}. Please schedule a new deploy on your bench and try again." ) site_migration = frappe.get_pg( { "doctype": "Site Migration", "site": name, "destination_bench": bench, "scheduled_time": scheduled_datetime, "skip_failing_patches": skip_failing_patches, } ).insert() if not scheduled_datetime: site_migration.start() @jingrow.whitelist() def get_site_config_standard_keys(): return frappe.get_all( "Site Config Key", {"internal": 0}, ["name", "key", "title", "description", "type"], order_by="title asc", ) @jingrow.whitelist() def fetch_sites_data_for_export(): from press.api.client import get_list sites = get_list( "Site", [ "name", "host_name", "plan.plan_title as plan_title", "cluster.title as cluster_title", "group.title as group_title", "group.version as version", "creation", ], start=0, limit=99999, ) tags = frappe.db.get_all( "Resource Tag", filters={"parenttype": "Site", "parent": ["in", [site.name for site in sites]]}, fields=["name", "tag_name", "parent"], ) for site in sites: site.tags = [tag.tag_name for tag in tags if tag.parent == site.name] return sites