diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed5e8ebf..e5c44260 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: push: branches: [ develop ] +concurrency: + group: ci-develop-${{ github.event_name }}-${{ github.event.number }} + cancel-in-progress: true + permissions: contents: read diff --git a/.github/workflows/easy-install.yml b/.github/workflows/easy-install.yml new file mode 100644 index 00000000..85c095c8 --- /dev/null +++ b/.github/workflows/easy-install.yml @@ -0,0 +1,32 @@ +name: "Easy Install Test" + +on: + pull_request: + workflow_dispatch: + push: + branches: [develop] + +concurrency: + group: easy-install-develop-${{ github.event_name }}-${{ github.event.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + easy-install-setup: + runs-on: ubuntu-latest + timeout-minutes: 60 + + name: Easy Install Test + steps: + - uses: actions/checkout@v3 + - name: Perform production easy install + run: | + python3 ${GITHUB_WORKSPACE}/easy-install.py -p -n actions_test --email test@frappe.io + docker compose -p actions_test exec backend bench version --format json + docker compose -p actions_test exec backend bench --site site1.local list-apps --format json + result=$(curl -sk https://127.0.0.1/api/method/ping | jq -r ."message") + if [[ "$result" == "pong" ]]; then echo "New instance works fine"; else exit 1; fi + docker compose -p actions_test down + docker volume prune -f diff --git a/README.md b/README.md index 16f61c88..5e4edcdd 100755 --- a/README.md +++ b/README.md @@ -31,17 +31,21 @@ Bench is a command-line utility that helps you to install, update, and manage mu ## Table of Contents - - [Installation](#installation) +- [Table of Contents](#table-of-contents) +- [Installation](#installation) - [Containerized Installation](#containerized-installation) + - [Easy Install Script](#easy-install-script) + - [Setup](#setup) + - [Arguments](#arguments) + - [Troubleshooting](#troubleshooting) - [Manual Installation](#manual-installation) - - [Usage](#basic-usage) - - [Custom Bench commands](#custom-bench-commands) - - [Bench Manager](#bench-manager) - - [Guides](#guides) - - [Resources](#resources) - - [Development](#development) - - [Releases](#releases) - - [License](#license) +- [Basic Usage](#basic-usage) +- [Custom Bench Commands](#custom-bench-commands) +- [Guides](#guides) +- [Resources](#resources) +- [Development](#development) +- [Releases](#releases) +- [License](#license) ## Installation @@ -53,7 +57,7 @@ The setup for each of these installations can be achieved in multiple ways: - [Containerized Installation](#containerized-installation) - [Manual Installation](#manual-installation) -We recommend using either the Docker Installation to setup a Production Environment. For Development, you may choose either of the two methods to setup an instance. +We recommend using Docker Installation to setup a Production Environment. For Development, you may choose either of the two methods to setup an instance. Otherwise, if you are looking to evaluate Frappe apps without hassle of hosting, you can try them [on frappecloud.com](https://frappecloud.com/). @@ -71,6 +75,53 @@ $ cd frappe_docker A quick setup guide for both the environments can be found below. For more details, check out the [Frappe/ERPNext Docker Repository](https://github.com/frappe/frappe_docker). +### Easy Install Script + +The Easy Install script should get you going with a Frappe/ERPNext setup with minimal manual intervention and effort. + +This script uses Docker with the [Frappe/ERPNext Docker Repository](https://github.com/frappe/frappe_docker) and can be used for both Development setup and Production setup. + +#### Setup + +Download the Easy Install script and execute it: + +```sh +$ wget https://raw.githubusercontent.com/frappe/bench/develop/easy-install.py +$ python3 easy-install.py --prod +``` + +This script will install docker on your system and will fetch the required containers, setup bench and a default ERPNext instance. + +The script will generate MySQL root password and an Administrator password for the Frappe/ERPNext instance, which will then be saved under `$HOME/passwords.txt` of the user used to setup the instance. +It will also generate a new compose file under `$HOME/-compose.yml`. + +When the setup is complete, you will be able to access the system at `http://`, wherein you can use the Administrator password to login. + +#### Arguments + +Here are the arguments for the easy-install script + +```txt +usage: easy-install.py [-h] [-p] [-d] [-s SITENAME] [-n PROJECT] [--email EMAIL] + +Install Frappe with Docker + +options: + -h, --help show this help message and exit + -p, --prod Setup Production System + -d, --dev Setup Development System + -s SITENAME, --sitename SITENAME + The Site Name for your production site + -n PROJECT, --project PROJECT + Project Name + --email EMAIL Add email for the SSL. +``` + +#### Troubleshooting + +In case the setup fails, the log file is saved under `$HOME/easy-install.log`. You may then + +- Create an Issue in this repository with the log file attached. ### Manual Installation diff --git a/bench/tests/test_base.py b/bench/tests/test_base.py index 9ca23de4..efc1e81f 100644 --- a/bench/tests/test_base.py +++ b/bench/tests/test_base.py @@ -15,12 +15,10 @@ from bench.bench import Bench PYTHON_VER = sys.version_info -FRAPPE_BRANCH = "version-12" +FRAPPE_BRANCH = "version-13-hotfix" if PYTHON_VER.major == 3: if PYTHON_VER.minor >= 10: FRAPPE_BRANCH = "develop" - if 7 >= PYTHON_VER.minor >= 9: - FRAPPE_BRANCH = "version-13" class TestBenchBase(unittest.TestCase): diff --git a/bench/tests/test_init.py b/bench/tests/test_init.py index 622ffc87..b0a871a4 100755 --- a/bench/tests/test_init.py +++ b/bench/tests/test_init.py @@ -46,7 +46,7 @@ class TestBenchInit(TestBenchBase): def test_multiple_benches(self): for bench_name in ("test-bench-1", "test-bench-2"): - self.init_bench(bench_name) + self.init_bench(bench_name, skip_assets=True) self.assert_common_site_config( "test-bench-1", @@ -96,7 +96,7 @@ class TestBenchInit(TestBenchBase): self.assertTrue(site_config[key]) def test_get_app(self): - self.init_bench("test-bench") + self.init_bench("test-bench", skip_assets=True) bench_path = os.path.join(self.benches_path, "test-bench") exec_cmd(f"bench get-app {TEST_FRAPPE_APP} --skip-assets", cwd=bench_path) self.assertTrue(os.path.exists(os.path.join(bench_path, "apps", TEST_FRAPPE_APP))) @@ -108,7 +108,7 @@ class TestBenchInit(TestBenchBase): @unittest.skipIf(FRAPPE_BRANCH != "develop", "only for develop branch") def test_get_app_resolve_deps(self): FRAPPE_APP = "healthcare" - self.init_bench("test-bench") + self.init_bench("test-bench", skip_assets=True) bench_path = os.path.join(self.benches_path, "test-bench") exec_cmd(f"bench get-app {FRAPPE_APP} --resolve-deps --skip-assets", cwd=bench_path) self.assertTrue(os.path.exists(os.path.join(bench_path, "apps", FRAPPE_APP))) @@ -126,7 +126,7 @@ class TestBenchInit(TestBenchBase): site_name = "install-app.test" bench_path = os.path.join(self.benches_path, "test-bench") - self.init_bench(bench_name) + self.init_bench(bench_name, skip_assets=True) exec_cmd( f"bench get-app {TEST_FRAPPE_APP} --branch master --skip-assets", cwd=bench_path ) @@ -154,7 +154,7 @@ class TestBenchInit(TestBenchBase): self.assertTrue(TEST_FRAPPE_APP in app_installed_on_site) def test_remove_app(self): - self.init_bench("test-bench") + self.init_bench("test-bench", skip_assets=True) bench_path = os.path.join(self.benches_path, "test-bench") exec_cmd( @@ -172,7 +172,7 @@ class TestBenchInit(TestBenchBase): self.assertFalse(os.path.exists(os.path.join(bench_path, "apps", TEST_FRAPPE_APP))) def test_switch_to_branch(self): - self.init_bench("test-bench") + self.init_bench("test-bench", skip_assets=True) bench_path = os.path.join(self.benches_path, "test-bench") app_path = os.path.join(bench_path, "apps", "frappe") diff --git a/bench/utils/app.py b/bench/utils/app.py index 5541b548..75891d5b 100644 --- a/bench/utils/app.py +++ b/bench/utils/app.py @@ -284,7 +284,7 @@ def get_current_version(app, bench_path="."): with open(init_path) as f: current_version = get_version_from_string(f.read()) - except AttributeError: + except (AttributeError, VersionNotFound): # backward compatibility with open(setup_path) as f: current_version = get_version_from_string(f.read(), field="version") diff --git a/easy-install.py b/easy-install.py new file mode 100755 index 00000000..26d5ab72 --- /dev/null +++ b/easy-install.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 + +import argparse +import logging +import os +import platform +import subprocess +import sys +import time +import urllib.request +from shutil import move, unpack_archive, which +from typing import Dict + +logging.basicConfig( + filename="easy-install.log", + filemode="w", + format="%(asctime)s - %(levelname)s - %(message)s", + level=logging.INFO, +) + + +def cprint(*args, level: int = 1): + """ + logs colorful messages + level = 1 : RED + level = 2 : GREEN + level = 3 : YELLOW + + default level = 1 + """ + CRED = "\033[31m" + CGRN = "\33[92m" + CYLW = "\33[93m" + reset = "\033[0m" + message = " ".join(map(str, args)) + if level == 1: + print(CRED, message, reset) + if level == 2: + print(CGRN, message, reset) + if level == 3: + print(CYLW, message, reset) + + +def clone_frappe_docker_repo() -> None: + try: + urllib.request.urlretrieve( + "https://github.com/frappe/frappe_docker/archive/refs/heads/main.zip", + "frappe_docker.zip", + ) + logging.info("Downloaded frappe_docker zip file from GitHub") + unpack_archive( + "frappe_docker.zip", "." + ) # Unzipping the frappe_docker.zip creates a folder "frappe_docker-main" + move("frappe_docker-main", "frappe_docker") + logging.info("Unzipped and Renamed frappe_docker") + os.remove("frappe_docker.zip") + logging.info("Removed the downloaded zip file") + except Exception as e: + logging.error("Download and unzip failed", exc_info=True) + cprint("\nCloning frappe_docker Failed\n\n", "[ERROR]: ", e, level=1) + + +def get_from_env(dir, file) -> Dict: + env_vars = {} + with open(os.path.join(dir, file)) as f: + for line in f: + if line.startswith("#") or not line.strip(): + continue + key, value = line.strip().split("=", 1) + env_vars[key] = value + return env_vars + + +def write_to_env(wd: str, site: str, db_pass: str, admin_pass: str, email: str) -> None: + site_name = site or "" + example_env = get_from_env(wd, "example.env") + with open(os.path.join(wd, ".env"), "w") as f: + f.writelines( + [ + f"FRAPPE_VERSION={example_env['FRAPPE_VERSION']}\n", # Defaults to latest version of Frappe + f"ERPNEXT_VERSION={example_env['ERPNEXT_VERSION']}\n", # defaults to latest version of ERPNext + f"DB_PASSWORD={db_pass}\n", + "DB_HOST=db\n", + "DB_PORT=3306\n", + "REDIS_CACHE=redis-cache:6379\n", + "REDIS_QUEUE=redis-queue:6379\n", + "REDIS_SOCKETIO=redis-socketio:6379\n", + f"LETSENCRYPT_EMAIL={email}\n", + f"FRAPPE_SITE_NAME_HEADER={site_name}\n", + f"SITE_ADMIN_PASS={admin_pass}", + ] + ) + + +def generate_pass(length: int = 12) -> str: + """Generate random hash using best available randomness source.""" + import math + import secrets + + if not length: + length = 56 + + return secrets.token_hex(math.ceil(length / 2))[:length] + + +def check_repo_exists() -> bool: + return os.path.exists(os.path.join(os.getcwd(), "frappe_docker")) + + +def setup_prod(project: str, sitename: str, email: str) -> None: + if check_repo_exists(): + compose_file_name = os.path.join(os.path.expanduser("~"), f"{project}-compose.yml") + docker_repo_path = os.path.join(os.getcwd(), "frappe_docker") + cprint( + "\nPlease refer to .example.env file in the frappe_docker folder to know which keys to set\n\n", + level=3, + ) + admin_pass = "" + db_pass = "" + with open(compose_file_name, "w") as f: + # Writing to compose file + if not os.path.exists(os.path.join(docker_repo_path, ".env")): + admin_pass = generate_pass() + db_pass = generate_pass(9) + write_to_env(docker_repo_path, sitename, db_pass, admin_pass, email) + cprint( + "\nA .env file is generated with basic configs. Please edit it to fit to your needs \n", + level=3, + ) + with open(os.path.join(os.path.expanduser("~"), "passwords.txt"), "w") as en: + en.writelines(f"ADMINISTRATOR_PASSWORD={admin_pass}\n") + en.writelines(f"MARIADB_ROOT_PASSWORD={db_pass}\n") + else: + env = get_from_env(docker_repo_path, ".env") + admin_pass = env["SITE_ADMIN_PASS"] + db_pass = env["DB_PASSWORD"] + try: + # TODO: Include flags for non-https and non-erpnext installation + subprocess.run( + [ + which("docker"), + "compose", + "--project-name", + project, + "-f", + "compose.yaml", + "-f", + "overrides/compose.mariadb.yaml", + "-f", + "overrides/compose.redis.yaml", + # "-f", "overrides/compose.noproxy.yaml", TODO: Add support for local proxying without HTTPs + "-f", + "overrides/compose.erpnext.yaml", + "-f", + "overrides/compose.https.yaml", + "--env-file", + ".env", + "config", + ], + cwd=docker_repo_path, + stdout=f, + check=True, + ) + + except Exception: + logging.error("Docker Compose generation failed", exc_info=True) + cprint("\nGenerating Compose File failed\n") + sys.exit(1) + try: + # Starting with generated compose file + subprocess.run( + [ + which("docker"), + "compose", + "-p", + project, + "-f", + compose_file_name, + "up", + "-d", + ], + check=True, + ) + logging.info(f"Docker Compose file generated at ~/{project}-compose.yml") + + except Exception as e: + logging.error("Prod docker-compose failed", exc_info=True) + cprint(" Docker Compose failed, please check the container logs\n", e) + sys.exit(1) + + cprint(f"\nCreating site: {sitename} \n", level=3) + + try: + subprocess.run( + [ + which("docker"), + "compose", + "-p", + project, + "exec", + "backend", + "bench", + "new-site", + sitename, + "--db-root-password", + db_pass, + "--admin-password", + admin_pass, + "--install-app", + "erpnext", + "--set-default", + ], + check=True, + ) + logging.info("New site creation completed") + except Exception as e: + logging.error("Bench site creation failed", exc_info=True) + cprint("Bench Site creation failed\n", e) + sys.exit(1) + else: + install_docker() + clone_frappe_docker_repo() + setup_prod(project, sitename, email) # Recursive + + +def setup_dev_instance(project: str): + if check_repo_exists(): + try: + subprocess.run( + [ + "docker", + "compose", + "-f", + "devcontainer-example/docker-compose.yml", + "--project-name", + project, + "up", + "-d", + ], + cwd=os.path.join(os.getcwd(), "frappe_docker"), + check=True, + ) + cprint( + "Please go through the Development Documentation: https://github.com/frappe/frappe_docker/tree/main/development to fully complete the setup.", + level=2, + ) + logging.info("Development Setup completed") + except Exception as e: + logging.error("Dev Environment setup failed", exc_info=True) + cprint("Setting Up Development Environment Failed\n", e) + else: + install_docker() + clone_frappe_docker_repo() + setup_dev_instance(project) # Recursion on goes brrrr + + +def install_docker(): + if which("docker") is not None: + return + cprint("Docker is not installed, Installing Docker...", level=3) + logging.info("Docker not found, installing Docker") + if platform.system() == "Darwin" or platform.system() == "Windows": + print( + f""" + This script doesn't install Docker on {"Mac" if platform.system()=="Darwin" else "Windows"}. + + Please go through the Docker Installation docs for your system and run this script again""" + ) + logging.debug("Docker setup failed due to platform is not Linux") + sys.exit(1) + try: + ps = subprocess.run( + ["curl", "-fsSL", "https://get.docker.com"], + capture_output=True, + check=True, + ) + subprocess.run(["/bin/bash"], input=ps.stdout, capture_output=True) + subprocess.run( + ["sudo", "usermod", "-aG", "docker", str(os.getenv("USER"))], check=True + ) + cprint("Waiting Docker to start", level=3) + time.sleep(10) + subprocess.run(["sudo", "systemctl", "restart", "docker.service"], check=True) + except Exception as e: + logging.error("Installing Docker failed", exc_info=True) + cprint("Failed to Install Docker\n", e) + cprint("\n Try Installing Docker Manually and re-run this script again\n") + sys.exit(1) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Install Frappe with Docker") + parser.add_argument( + "-p", "--prod", help="Setup Production System", action="store_true" + ) + parser.add_argument( + "-d", "--dev", help="Setup Development System", action="store_true" + ) + parser.add_argument( + "-s", + "--sitename", + help="The Site Name for your production site", + default="site1.local", + ) + parser.add_argument("-n", "--project", help="Project Name", default="frappe") + parser.add_argument( + "--email", help="Add email for the SSL.", required="--prod" in sys.argv + ) + args = parser.parse_args() + if args.dev: + cprint("\nSetting Up Development Instance\n", level=2) + logging.info("Running Development Setup") + setup_dev_instance(args.project) + elif args.prod: + cprint("\nSetting Up Production Instance\n", level=2) + logging.info("Running Production Setup") + if "example.com" in args.email: + cprint("Emails with example.com not acceptable", level=1) + sys.exit(1) + setup_prod(args.project, args.sitename, args.email) + else: + parser.print_help() diff --git a/pyproject.toml b/pyproject.toml index cab5236a..2d535b1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ authors = [ classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", - "License :: OSI Approved :: GNU Affero General Public License v3", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Natural Language :: English", "Operating System :: MacOS", "Operating System :: OS Independent",