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..04c87349 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 @@ -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 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/easy-install.py b/easy-install.py new file mode 100644 index 00000000..6faf5c13 --- /dev/null +++ b/easy-install.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +import argparse +import subprocess +import os +import sys +import time +import urllib.request +import logging +import platform +from shutil import which, unpack_archive, move +from hashlib import sha224 +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 as e: + 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) \ No newline at end of file