From a58a274bc8216149f79c8c7d123b20401cb4f784 Mon Sep 17 00:00:00 2001 From: Bohdan Kucherivayi Date: Tue, 13 Feb 2024 15:44:14 +0200 Subject: [PATCH] feat: update erp docker setup --- .devcontainer/devcontainer.json | 27 ++ .../docker-compose.yml | 31 +- example.env => .env.example | 24 +- .github/ISSUE_TEMPLATE/bug_report.md | 34 -- .github/ISSUE_TEMPLATE/feature_request.md | 23 - .../question-about-using-frappe_docker.md | 12 - .github/PULL_REQUEST_TEMPLATE.md | 7 - .github/dependabot.yml | 26 -- .github/workflows/build_bench.yml | 4 - .github/workflows/build_develop.yml | 33 -- .github/workflows/lint.yml | 35 -- .github/workflows/pre-commit-autoupdate.yml | 26 -- .github/workflows/stale.yml | 18 - .gitignore | 15 +- .pre-commit-config.yaml | 53 --- .shellcheckrc | 1 - CODE_OF_CONDUCT.md | 76 ---- CONTRIBUTING.md | 81 ---- LICENSE | 21 - README.md | 60 --- compose.yaml | 15 +- devcontainer-example/devcontainer.json | 25 -- .../{vscode-example => .vscode}/launch.json | 0 development/apps-example.json | 6 - development/apps.json | 14 + development/installer.py | 10 +- docker-bake.hcl | 47 +- docs/backup-and-push-cronjob.md | 58 --- docs/bench-console-and-vscode-debugger.md | 16 - docs/build-version-10-images.md | 16 - ...om-containers-for-local-app-development.md | 13 - docs/custom-apps.md | 113 ----- docs/development.md | 417 ------------------ docs/environment-variables.md | 56 --- ...Desktop Screenshot - Resources section.png | Bin 32144 -> 0 bytes ... Manual Screenshot - Resources section.png | Bin 51409 -> 0 bytes docs/list-of-containers.md | 58 --- docs/migrate-from-multi-image-setup.md | 112 ----- docs/port-based-multi-tenancy.md | 69 --- docs/setup-options.md | 131 ------ docs/setup_for_linux_mac.md | 225 ---------- docs/single-compose-setup.md | 38 -- docs/single-server-example.md | 288 ------------ docs/site-operations.md | 85 ---- docs/troubleshoot.md | 55 --- images/bench/Dockerfile | 2 - images/custom/Containerfile | 86 ++-- images/production/Containerfile | 61 +-- install_x11_deps.sh | 0 overrides/compose.multi-bench-ssl.yaml | 14 - overrides/compose.multi-bench.yaml | 54 --- overrides/compose.postgres.yaml | 18 - overrides/compose.traefik-ssl.yaml | 48 -- pwd.yml | 188 -------- resources/nginx-entrypoint.sh | 10 +- resources/nginx-template.conf | 2 +- setup.cfg | 12 - tests/__init__.py | 0 tests/_check_connections.py | 52 --- tests/_check_website_theme.py | 17 - tests/_create_bucket.py | 19 - tests/_ping_frappe_connections.py | 26 -- tests/compose.ci.yaml | 21 - tests/conftest.py | 168 ------- tests/test_frappe_docker.py | 151 ------- tests/utils.py | 89 ---- 66 files changed, 191 insertions(+), 3321 deletions(-) create mode 100644 .devcontainer/devcontainer.json rename {devcontainer-example => .devcontainer}/docker-compose.yml (87%) rename example.env => .env.example (88%) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md delete mode 100644 .github/ISSUE_TEMPLATE/question-about-using-frappe_docker.md delete mode 100644 .github/PULL_REQUEST_TEMPLATE.md delete mode 100644 .github/dependabot.yml delete mode 100644 .github/workflows/build_develop.yml delete mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/pre-commit-autoupdate.yml delete mode 100644 .github/workflows/stale.yml delete mode 100644 .pre-commit-config.yaml delete mode 100644 .shellcheckrc delete mode 100644 CODE_OF_CONDUCT.md delete mode 100644 CONTRIBUTING.md delete mode 100644 LICENSE delete mode 100644 README.md delete mode 100644 devcontainer-example/devcontainer.json rename development/{vscode-example => .vscode}/launch.json (100%) delete mode 100644 development/apps-example.json create mode 100644 development/apps.json mode change 100755 => 100644 development/installer.py delete mode 100644 docs/backup-and-push-cronjob.md delete mode 100644 docs/bench-console-and-vscode-debugger.md delete mode 100644 docs/build-version-10-images.md delete mode 100644 docs/connect-to-localhost-services-from-containers-for-local-app-development.md delete mode 100644 docs/custom-apps.md delete mode 100644 docs/development.md delete mode 100644 docs/environment-variables.md delete mode 100644 docs/images/Docker Desktop Screenshot - Resources section.png delete mode 100644 docs/images/Docker Manual Screenshot - Resources section.png delete mode 100644 docs/list-of-containers.md delete mode 100644 docs/migrate-from-multi-image-setup.md delete mode 100644 docs/port-based-multi-tenancy.md delete mode 100644 docs/setup-options.md delete mode 100644 docs/setup_for_linux_mac.md delete mode 100644 docs/single-compose-setup.md delete mode 100644 docs/single-server-example.md delete mode 100644 docs/site-operations.md delete mode 100644 docs/troubleshoot.md mode change 100755 => 100644 install_x11_deps.sh delete mode 100644 overrides/compose.multi-bench-ssl.yaml delete mode 100644 overrides/compose.multi-bench.yaml delete mode 100644 overrides/compose.postgres.yaml delete mode 100644 overrides/compose.traefik-ssl.yaml delete mode 100644 pwd.yml mode change 100755 => 100644 resources/nginx-entrypoint.sh delete mode 100644 setup.cfg delete mode 100644 tests/__init__.py delete mode 100644 tests/_check_connections.py delete mode 100644 tests/_check_website_theme.py delete mode 100644 tests/_create_bucket.py delete mode 100644 tests/_ping_frappe_connections.py delete mode 100644 tests/compose.ci.yaml delete mode 100644 tests/conftest.py delete mode 100644 tests/test_frappe_docker.py delete mode 100644 tests/utils.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..e965f22a --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,27 @@ +{ + "name": "Frappe Bench", + "forwardPorts": [8000, 9000, 6787], + "remoteUser": "frappe", + "customizations": { + "extensions": [ + "ms-python.python", + "ms-vscode.live-server", + "grapecity.gc-excelviewer", + "mtxr.sqltools", + "visualstudioexptteam.vscodeintellicode", + ], + "vscode": { + "settings": { + "terminal.integrated.profiles.linux": { + "frappe bash": { + "path": "/bin/zsh" + } + } + } + } + }, + "dockerComposeFile": "./docker-compose.yml", + "service": "frappe", + "workspaceFolder": "/workspace/development", + "shutdownAction": "stopCompose" +} diff --git a/devcontainer-example/docker-compose.yml b/.devcontainer/docker-compose.yml similarity index 87% rename from devcontainer-example/docker-compose.yml rename to .devcontainer/docker-compose.yml index e27b8daa..1f1e0fcf 100644 --- a/devcontainer-example/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -22,18 +22,19 @@ services: # Enable Mailpit if you need to test outgoing mail services # See https://mailpit.axllent.org/ - # mailpit: - # image: axllent/mailpit - # volumes: - # - mailpit-data:/data - # ports: - # - 8025:8025 - # - 1025:1025 - # environment: - # MP_MAX_MESSAGES: 5000 - # MP_DATA_FILE: /data/mailpit.db - # MP_SMTP_AUTH_ACCEPT_ANY: 1 - # MP_SMTP_AUTH_ALLOW_INSECURE: 1 + + mailpit: + image: axllent/mailpit + volumes: + - mailpit-data:/data + ports: + - 8025:8025 + - 1025:1025 + environment: + MP_MAX_MESSAGES: 5000 + MP_DATA_FILE: /data/mailpit.db + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 redis-cache: image: docker.io/redis:alpine @@ -49,11 +50,12 @@ services: volumes: - ..:/workspace:cached # Enable if you require git cloning - # - ${HOME}/.ssh:/home/frappe/.ssh + - ${HOME}/.ssh:/home/frappe/.ssh working_dir: /workspace/development ports: - 8000-8005:8000-8005 - 9000-9005:9000-9005 + # enable the below service if you need Cypress UI Tests to be executed # Before enabling ensure install_x11_deps.sh has been executed and display variable is exported. # Run install_x11_deps.sh again if DISPLAY is not set @@ -81,7 +83,8 @@ services: # - /tmp/.X11-unix:/tmp/.X11-unix # - ..:/workspace:z,cached # network_mode: "host" + volumes: mariadb-data: #postgresql-data: - #mailpit-data: + mailpit-data: diff --git a/example.env b/.env.example similarity index 88% rename from example.env rename to .env.example index 0566760f..212bbc3d 100644 --- a/example.env +++ b/.env.example @@ -1,19 +1,19 @@ # Reference: https://github.com/frappe/frappe_docker/blob/main/docs/images-and-compose-files.md -ERPNEXT_VERSION=v15.13.0 +ERPNEXT_VERSION=v15.12.2 DB_PASSWORD=123 # Only if you use external database -DB_HOST= -DB_PORT= +# DB_HOST= +# DB_PORT= # Only if you use external Redis -REDIS_CACHE= -REDIS_QUEUE= +# REDIS_CACHE= +# REDIS_QUEUE= # Only with HTTPS override -LETSENCRYPT_EMAIL=mail@example.com +# LETSENCRYPT_EMAIL= # These environment variables are not required. @@ -24,26 +24,26 @@ LETSENCRYPT_EMAIL=mail@example.com FRAPPE_SITE_NAME_HEADER= # Default value is `127.0.0.1`. Set IP address as our trusted upstream address. -UPSTREAM_REAL_IP_ADDRESS= +# UPSTREAM_REAL_IP_ADDRESS= # Default value is `X-Forwarded-For`. Set request header field whose value will be used to replace the client address -UPSTREAM_REAL_IP_HEADER= +# UPSTREAM_REAL_IP_HEADER= # Allowed values are on|off. Default value is `off`. If recursive search is disabled, # the original client address that matches one of the trusted addresses # is replaced by the last address sent in the request header field defined by the real_ip_header directive. # If recursive search is enabled, the original client address that matches one of the trusted addresses is replaced by the last non-trusted address sent in the request header field. -UPSTREAM_REAL_IP_RECURSIVE= +# UPSTREAM_REAL_IP_RECURSIVE= # All Values Allowed by nginx proxy_read_timeout are allowed, default value is 120s # Useful if you have longrunning print formats or slow loading sites -PROXY_READ_TIMEOUT= +# PROXY_READ_TIMEOUT= # All Values allowed by nginx client_max_body_size are allowed, default value is 50m # Necessary if the upload limit in the frappe application is increased -CLIENT_MAX_BODY_SIZE= +# CLIENT_MAX_BODY_SIZE= # List of sites for letsencrypt certificates quoted with backtick (`) and separated by comma (,) # More https://doc.traefik.io/traefik/routing/routers/#rule # About acme https://doc.traefik.io/traefik/https/acme/#domain-definition -SITES=`erp.example.com` +SITES=`erp.zapal.tech` diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index d4c7a8c1..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -name: Bug report -about: Report a bug encountered while using the Frappe_docker -labels: bug ---- - - - -## Description of the issue - -## Context information (for bug reports) - -## Steps to reproduce the issue - -1. -2. -3. - -### Observed result - -### Expected result - -### Stacktrace / full error message if available - -``` -(paste here) -``` diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 15c410eb..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: Feature request -about: Suggest an idea to improve frappe_docker -labels: enhancement ---- - - - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/question-about-using-frappe_docker.md b/.github/ISSUE_TEMPLATE/question-about-using-frappe_docker.md deleted file mode 100644 index 6b4b9cbf..00000000 --- a/.github/ISSUE_TEMPLATE/question-about-using-frappe_docker.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: Question about using Frappe/Frappe Apps -about: Ask how to do something -labels: question ---- - - diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 6c9fc532..00000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,7 +0,0 @@ -> Please provide enough information so that others can review your pull request: - - - -> Explain the **details** for making this change. What existing problem does the pull request solve? - - diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 8fb9a8c8..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,26 +0,0 @@ -version: 2 -updates: - - package-ecosystem: github-actions - directory: / - schedule: - interval: daily - - - package-ecosystem: docker - directory: images/bench - schedule: - interval: daily - - - package-ecosystem: docker - directory: images/production - schedule: - interval: daily - - - package-ecosystem: docker - directory: images/custom - schedule: - interval: daily - - - package-ecosystem: pip - directory: / - schedule: - interval: daily diff --git a/.github/workflows/build_bench.yml b/.github/workflows/build_bench.yml index 26003f28..da5a0762 100644 --- a/.github/workflows/build_bench.yml +++ b/.github/workflows/build_bench.yml @@ -9,10 +9,6 @@ on: - docker-bake.hcl - .github/workflows/build_bench.yml - schedule: - # Every day at 12:00 pm - - cron: 0 0 * * * - workflow_dispatch: jobs: diff --git a/.github/workflows/build_develop.yml b/.github/workflows/build_develop.yml deleted file mode 100644 index e37c5858..00000000 --- a/.github/workflows/build_develop.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Develop build - -on: - pull_request: - branches: - - main - paths: - - images/production/** - - overrides/** - - tests/** - - compose.yaml - - docker-bake.hcl - - example.env - - .github/workflows/build_develop.yml - - schedule: - # Every day at 12:00 pm - - cron: 0 0 * * * - - workflow_dispatch: - -jobs: - build: - uses: ./.github/workflows/docker-build-push.yml - with: - repo: erpnext - version: develop - push: ${{ github.repository == 'frappe/frappe_docker' && github.event_name != 'pull_request' }} - python_version: 3.11.6 - node_version: 18.18.2 - secrets: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index ce4ab6ee..00000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Lint - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.10.6" - - # For shfmt pre-commit hook - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: "^1.14" - - - name: Install pre-commit - run: pip install -U pre-commit - - - name: Lint - run: pre-commit run --color=always --all-files - env: - GO111MODULE: on diff --git a/.github/workflows/pre-commit-autoupdate.yml b/.github/workflows/pre-commit-autoupdate.yml deleted file mode 100644 index 1fdeaeea..00000000 --- a/.github/workflows/pre-commit-autoupdate.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Autoupdate pre-commit hooks - -on: - schedule: - # Every day at 7 am - - cron: 0 7 * * * - -jobs: - pre-commit-autoupdate: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Update pre-commit hooks - uses: vrslev/pre-commit-autoupdate@v1.0.0 - - - name: Create Pull Request - uses: peter-evans/create-pull-request@v6 - with: - branch: pre-commit-autoupdate - title: "chore(deps): Update pre-commit hooks" - commit-message: "chore(deps): Update pre-commit hooks" - body: Update pre-commit hooks - labels: dependencies,development - delete-branch: True diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index 024ade1c..00000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Mark stale issues and pull requests - -on: - schedule: - # Every day at 12:00 pm - - cron: 0 0 * * * - -jobs: - stale: - runs-on: ubuntu-latest - steps: - - uses: actions/stale@v9 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - stale-issue-message: This issue has been automatically marked as stale. You have a week to explain why you believe this is an error. - stale-pr-message: This PR has been automatically marked as stale. You have a week to explain why you believe this is an error. - stale-issue-label: no-issue-activity - stale-pr-label: no-pr-activity diff --git a/.gitignore b/.gitignore index 94a9fe2f..569cd62d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,19 +7,8 @@ sites development/* !development/README.md !development/installer.py -!development/apps-example.json -!development/vscode-example/ - -# Pycharm -.idea - -# VS Code -.vscode/** -!.vscode/extensions.json - -# VS Code devcontainer -.devcontainer -*.code-workspace +!development/apps.json +!development/.vscode # Python *.pyc diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 41d81fdb..00000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,53 +0,0 @@ -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 - hooks: - - id: check-executables-have-shebangs - - id: check-shebang-scripts-are-executable - - id: trailing-whitespace - - id: end-of-file-fixer - - - repo: https://github.com/asottile/pyupgrade - rev: v3.15.0 - hooks: - - id: pyupgrade - args: [--py37-plus] - - - repo: https://github.com/psf/black - rev: 24.1.1 - hooks: - - id: black - - - repo: https://github.com/pycqa/isort - rev: 5.13.2 - hooks: - - id: isort - - - repo: https://github.com/pre-commit/mirrors-prettier - rev: v4.0.0-alpha.8 - hooks: - - id: prettier - - - repo: https://github.com/codespell-project/codespell - rev: v2.2.6 - hooks: - - id: codespell - args: - - -L - - "ro" - - - repo: local - hooks: - - id: shfmt - name: shfmt - language: golang - additional_dependencies: [mvdan.cc/sh/v3/cmd/shfmt@latest] - entry: shfmt - args: [-w] - types: [shell] - - - repo: https://github.com/shellcheck-py/shellcheck-py - rev: v0.9.0.6 - hooks: - - id: shellcheck - args: [-x] diff --git a/.shellcheckrc b/.shellcheckrc deleted file mode 100644 index 8226afb6..00000000 --- a/.shellcheckrc +++ /dev/null @@ -1 +0,0 @@ -external-sources=true diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 55c27d81..00000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,76 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, sex characteristics, gender identity and expression, -level of experience, education, socio-economic status, nationality, personal -appearance, race, religion, or sexual identity and orientation. - -## Our Standards - -Examples of behavior that contributes to creating a positive environment -include: - -- Using welcoming and inclusive language -- Being respectful of differing viewpoints and experiences -- Gracefully accepting constructive criticism -- Focusing on what is best for the community -- Showing empathy towards other community members - -Examples of unacceptable behavior by participants include: - -- The use of sexualized language or imagery and unwelcome sexual attention or - advances -- Trolling, insulting/derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information, such as a physical or electronic - address, without explicit permission -- Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Our Responsibilities - -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. - -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. - -## Scope - -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at hello@frappe.io. All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see -https://www.contributor-covenant.org/faq diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 3cc671ee..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,81 +0,0 @@ -# Contribution Guidelines - -Before publishing a PR, please test builds locally. - -On each PR that contains changes relevant to Docker builds, images are being built and tested in our CI (GitHub Actions). - -> :evergreen_tree: Please be considerate when pushing commits and opening PR for multiple branches, as the process of building images uses energy and contributes to global warming. - -## Lint - -We use `pre-commit` framework to lint the codebase before committing. -First, you need to install pre-commit with pip: - -```shell -pip install pre-commit -``` - -Also you can use brew if you're on Mac: - -```shell -brew install pre-commit -``` - -To setup _pre-commit_ hook, run: - -```shell -pre-commit install -``` - -To run all the files in repository, run: - -```shell -pre-commit run --all-files -``` - -## Build - -We use [Docker Buildx Bake](https://docs.docker.com/engine/reference/commandline/buildx_bake/). To build the images, run command below: - -```shell -FRAPPE_VERSION=... ERPNEXT_VERSION=... docker buildx bake -``` - -Available targets can be found in `docker-bake.hcl`. - -## Test - -We use [pytest](https://pytest.org) for our integration tests. - -Install Python test requirements: - -```shell -python3 -m venv venv -source venv/bin/activate -pip install -r requirements-test.txt -``` - -Run pytest: - -```shell -pytest -``` - -# Documentation - -Place relevant markdown files in the `docs` directory and index them in README.md located at the root of repo. - -# Frappe and ERPNext updates - -Each Frappe/ERPNext release triggers new stable images builds as well as bump to helm chart. - -# Maintenance - -In case of new release of Debian. e.g. bullseye to bookworm. Change following files: - -- `images/erpnext/Containerfile` and `images/custom/Containerfile`: Change the files to use new debian release, make sure new python version tag that is available on new debian release image. e.g. 3.9.9 (bullseye) to 3.9.17 (bookworm) or 3.10.5 (bullseye) to 3.10.12 (bookworm). Make sure apt-get packages and wkhtmltopdf version are also upgraded accordingly. -- `images/bench/Dockerfile`: Change the files to use new debian release. Make sure apt-get packages and wkhtmltopdf version are also upgraded accordingly. - -Change following files on release of ERPNext - -- `.github/workflows/build_stable.yml`: Add the new release step under `jobs` and remove the unmaintained one. e.g. In case v12, v13 available, v14 will be added and v12 will be removed on release of v14. Also change the `needs:` for later steps to `v14` from `v13`. diff --git a/LICENSE b/LICENSE deleted file mode 100644 index c4e7b3dc..00000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2017 Frappe Technologies Pvt. Ltd. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md deleted file mode 100644 index 436e3f4a..00000000 --- a/README.md +++ /dev/null @@ -1,60 +0,0 @@ -[![Build Stable](https://github.com/frappe/frappe_docker/actions/workflows/build_stable.yml/badge.svg)](https://github.com/frappe/frappe_docker/actions/workflows/build_stable.yml) -[![Build Develop](https://github.com/frappe/frappe_docker/actions/workflows/build_develop.yml/badge.svg)](https://github.com/frappe/frappe_docker/actions/workflows/build_develop.yml) - -Everything about [Frappe](https://github.com/frappe/frappe) and [ERPNext](https://github.com/frappe/erpnext) in containers. - -# Getting Started - -To get started, you need Docker, docker-compose and git setup on your machine. For Docker basics and best practices. Refer Docker [documentation](http://docs.docker.com). -After that, clone this repo: - -```sh -git clone https://github.com/frappe/frappe_docker -cd frappe_docker -``` - -### Try in Play With Docker - - - Try in PWD - - -Wait for 5 minutes for ERPNext site to be created or check `create-site` container logs before opening browser on port 8080. (username: `Administrator`, password: `admin`) - -# Documentation - -### [Production](#production) - -- [List of containers](docs/list-of-containers.md) -- [Single Compose Setup](docs/single-compose-setup.md) -- [Environment Variables](docs/environment-variables.md) -- [Single Server Example](docs/single-server-example.md) -- [Setup Options](docs/setup-options.md) -- [Site Operations](docs/site-operations.md) -- [Backup and Push Cron Job](docs/backup-and-push-cronjob.md) -- [Port Based Multi Tenancy](docs/port-based-multi-tenancy.md) -- [Migrate from multi-image setup](docs/migrate-from-multi-image-setup.md) -- [running on linux/mac](docs/setup_for_linux_mac.md) - -### [Custom Images](#custom-images) - -- [Custom Apps](docs/custom-apps.md) -- [Build Version 10 Images](docs/build-version-10-images.md) - -### [Development](#development) - -- [Development using containers](docs/development.md) -- [Bench Console and VSCode Debugger](docs/bench-console-and-vscode-debugger.md) -- [Connect to localhost services](docs/connect-to-localhost-services-from-containers-for-local-app-development.md) - -### [Troubleshoot](docs/troubleshoot.md) - -# Contributing - -If you want to contribute to this repo refer to [CONTRIBUTING.md](CONTRIBUTING.md) - -This repository is only for container related stuff. You also might want to contribute to: - -- [Frappe framework](https://github.com/frappe/frappe#contributing), -- [ERPNext](https://github.com/frappe/erpnext#contributing), -- [Frappe Bench](https://github.com/frappe/bench). diff --git a/compose.yaml b/compose.yaml index f4d81b63..b8ebd2cb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,8 +1,5 @@ x-customizable-image: &customizable_image - # By default the image used only contains the `frappe` and `erpnext` apps. - # See https://github.com/frappe/frappe_docker/blob/main/docs/custom-apps.md - # about using custom images. - image: frappe/erpnext:${ERPNEXT_VERSION:?No ERPNext version set} + image: zapal-tech/erp:latest x-depends-on-configurator: &depends_on_configurator depends_on: @@ -12,7 +9,7 @@ x-depends-on-configurator: &depends_on_configurator x-backend-defaults: &backend_defaults <<: [*depends_on_configurator, *customizable_image] volumes: - - sites:/home/frappe/frappe-bench/sites + - ./sites:/home/frappe/frappe-bench/sites services: configurator: @@ -55,7 +52,7 @@ services: PROXY_READ_TIMEOUT: ${PROXY_READ_TIMEOUT:-120} CLIENT_MAX_BODY_SIZE: ${CLIENT_MAX_BODY_SIZE:-50m} volumes: - - sites:/home/frappe/frappe-bench/sites + - ./sites:/home/frappe/frappe-bench/sites depends_on: - backend - websocket @@ -66,7 +63,7 @@ services: - node - /home/frappe/frappe-bench/apps/frappe/socketio.js volumes: - - sites:/home/frappe/frappe-bench/sites + - ./sites:/home/frappe/frappe-bench/sites queue-short: <<: *backend_defaults @@ -79,7 +76,3 @@ services: scheduler: <<: *backend_defaults command: bench schedule - -# ERPNext requires local assets access (Frappe does not) -volumes: - sites: diff --git a/devcontainer-example/devcontainer.json b/devcontainer-example/devcontainer.json deleted file mode 100644 index d80b845d..00000000 --- a/devcontainer-example/devcontainer.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "Frappe Bench", - "forwardPorts": [8000, 9000, 6787], - "remoteUser": "frappe", - "settings": { - "terminal.integrated.profiles.linux": { - "frappe bash": { - "path": "/bin/bash", - }, - }, - "terminal.integrated.defaultProfile.linux": "frappe bash", - "debug.node.autoAttach": "disabled", - }, - "dockerComposeFile": "./docker-compose.yml", - "service": "frappe", - "workspaceFolder": "/workspace/development", - "shutdownAction": "stopCompose", - "extensions": [ - "ms-python.python", - "ms-vscode.live-server", - "grapecity.gc-excelviewer", - "mtxr.sqltools", - "visualstudioexptteam.vscodeintellicode", - ], -} diff --git a/development/vscode-example/launch.json b/development/.vscode/launch.json similarity index 100% rename from development/vscode-example/launch.json rename to development/.vscode/launch.json diff --git a/development/apps-example.json b/development/apps-example.json deleted file mode 100644 index 513d3d10..00000000 --- a/development/apps-example.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - { - "url": "https://github.com/frappe/erpnext.git", - "branch": "version-15" - } -] diff --git a/development/apps.json b/development/apps.json new file mode 100644 index 00000000..4bf885c7 --- /dev/null +++ b/development/apps.json @@ -0,0 +1,14 @@ +[ + { + "url": "https://github.com/zapal-tech/erp-erpnext.git", + "branch": "version-15" + }, + { + "url": "https://github.com/zapal-tech/erp-hrms.git", + "branch": "version-15" + }, + { + "url": "https://github.com/zapal-tech/erp-insights.git", + "branch": "develop" + } +] \ No newline at end of file diff --git a/development/installer.py b/development/installer.py old mode 100755 new mode 100644 index 44aca431..cdc0b0d2 --- a/development/installer.py +++ b/development/installer.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python3.11 import argparse import os import subprocess @@ -40,8 +40,8 @@ def get_args_parser(): "--apps-json", action="store", type=str, - help="Path to apps.json, default: apps-example.json", - default="apps-example.json", + help="Path to apps.json, default: apps.json", + default="apps.json", ) # noqa: E501 parser.add_argument( "-b", @@ -64,8 +64,8 @@ def get_args_parser(): "--frappe-repo", action="store", type=str, - help="frappe repo to use, default: https://github.com/frappe/frappe", # noqa: E501 - default="https://github.com/frappe/frappe", + help="frappe repo to use, default: https://github.com/zapal-tech/erp-frappe", # noqa: E501 + default="https://github.com/zapal-tech/erp-frappe", ) parser.add_argument( "-t", diff --git a/docker-bake.hcl b/docker-bake.hcl index f2a9fc70..9d2b8cf3 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -1,8 +1,5 @@ -# Docker Buildx Bake build definition file -# Reference: https://github.com/docker/buildx/blob/master/docs/reference/buildx_bake.md - variable "REGISTRY_USER" { - default = "frappe" + default = "zapal-tech" } variable PYTHON_VERSION { @@ -13,19 +10,27 @@ variable NODE_VERSION { } variable "FRAPPE_VERSION" { - default = "develop" + default = "version-15" } variable "ERPNEXT_VERSION" { - default = "develop" + default = "version-15" +} + +variable "HRMS_VERSION" { + default = "version-15" } variable "FRAPPE_REPO" { - default = "https://github.com/frappe/frappe" + default = "https://github.com/zapal-tech/erp-frappe" } variable "ERPNEXT_REPO" { - default = "https://github.com/frappe/erpnext" + default = "https://github.com/zapal-tech/erp-erpnext" +} + +variable "HRMS_REPO" { + default = "https://github.com/zapal-tech/erp-hrms" } variable "BENCH_REPO" { @@ -44,10 +49,7 @@ target "bench" { } context = "images/bench" target = "bench" - tags = [ - "frappe/bench:${LATEST_BENCH_RELEASE}", - "frappe/bench:latest", - ] + tags = ["frappe/bench:${LATEST_BENCH_RELEASE}", "frappe/bench:latest"] } target "bench-test" { @@ -59,35 +61,34 @@ target "bench-test" { # Base for all other targets group "default" { - targets = ["erpnext"] + targets = ["erp"] } function "tag" { params = [repo, version] - result = [ - # If `version` param is develop (development build) then use tag `latest` - "${version}" == "develop" ? "${REGISTRY_USER}/${repo}:latest" : "${REGISTRY_USER}/${repo}:${version}", - # Make short tag for major version if possible. For example, from v13.16.0 make v13. - can(regex("(v[0-9]+)[.]", "${version}")) ? "${REGISTRY_USER}/${repo}:${regex("(v[0-9]+)[.]", "${version}")[0]}" : "", - ] + result = ["${REGISTRY_USER}/${repo}:latest", "${REGISTRY_USER}/${repo}:${version}"] } target "default-args" { args = { FRAPPE_PATH = "${FRAPPE_REPO}" - ERPNEXT_PATH = "${ERPNEXT_REPO}" + ERPNEXT_REPO = "${ERPNEXT_REPO}" + HRMS_REPO = "${HRMS_REPO}" + INSIGHTS_REPO = "${INSIGHTS_REPO}" BENCH_REPO = "${BENCH_REPO}" FRAPPE_BRANCH = "${FRAPPE_VERSION}" ERPNEXT_BRANCH = "${ERPNEXT_VERSION}" + HRMS_BRANCH = "${HRMS_VERSION}" + INSIGHTS_BRANCH = "${INSIGHTS_VERSION}" PYTHON_VERSION = "${PYTHON_VERSION}" NODE_VERSION = "${NODE_VERSION}" } } -target "erpnext" { +target "erp" { inherits = ["default-args"] context = "." dockerfile = "images/production/Containerfile" - target = "erpnext" - tags = tag("erpnext", "${ERPNEXT_VERSION}") + target = "erp" + tags = tag("erp", "${ERPNEXT_VERSION}") } diff --git a/docs/backup-and-push-cronjob.md b/docs/backup-and-push-cronjob.md deleted file mode 100644 index ce5d8b49..00000000 --- a/docs/backup-and-push-cronjob.md +++ /dev/null @@ -1,58 +0,0 @@ -Create backup service or stack. - -```yaml -# backup-job.yml -version: "3.7" -services: - backup: - image: frappe/erpnext:${VERSION} - entrypoint: ["bash", "-c"] - command: - - | - bench --site all backup - ## Uncomment for restic snapshots. - # restic snapshots || restic init - # restic backup sites - ## Uncomment to keep only last n=30 snapshots. - # restic forget --group-by=paths --keep-last=30 --prune - environment: - # Set correct environment variables for restic - - RESTIC_REPOSITORY=s3:https://s3.endpoint.com/restic - - AWS_ACCESS_KEY_ID=access_key - - AWS_SECRET_ACCESS_KEY=secret_access_key - - RESTIC_PASSWORD=restic_password - volumes: - - "sites:/home/frappe/frappe-bench/sites" - networks: - - erpnext-network - -networks: - erpnext-network: - external: true - name: ${PROJECT_NAME:-erpnext}_default - -volumes: - sites: - external: true - name: ${PROJECT_NAME:-erpnext}_sites -``` - -In case of single docker host setup, add crontab entry for backup every 6 hours. - -``` -0 */6 * * * /usr/local/bin/docker-compose -f /path/to/backup-job.yml up -d > /dev/null -``` - -Or - -``` -0 */6 * * * docker compose -p erpnext exec backend bench --site all backup --with-files > /dev/null -``` - -Notes: - -- Make sure `docker-compose` or `docker compose` is available in path during execution. -- Change the cron string as per need. -- Set the correct project name in place of `erpnext`. -- For Docker Swarm add it as a [swarm-cronjob](https://github.com/crazy-max/swarm-cronjob) -- Add it as a `CronJob` in case of Kubernetes cluster. diff --git a/docs/bench-console-and-vscode-debugger.md b/docs/bench-console-and-vscode-debugger.md deleted file mode 100644 index d506be0f..00000000 --- a/docs/bench-console-and-vscode-debugger.md +++ /dev/null @@ -1,16 +0,0 @@ -Add the following configuration to `launch.json` `configurations` array to start bench console and use debugger. Replace `development.localhost` with appropriate site. Also replace `frappe-bench` with name of the bench directory. - -```json -{ - "name": "Bench Console", - "type": "python", - "request": "launch", - "program": "${workspaceFolder}/frappe-bench/apps/frappe/frappe/utils/bench_helper.py", - "args": ["frappe", "--site", "development.localhost", "console"], - "pythonPath": "${workspaceFolder}/frappe-bench/env/bin/python", - "cwd": "${workspaceFolder}/frappe-bench/sites", - "env": { - "DEV_SERVER": "1" - } -} -``` diff --git a/docs/build-version-10-images.md b/docs/build-version-10-images.md deleted file mode 100644 index 613f06ee..00000000 --- a/docs/build-version-10-images.md +++ /dev/null @@ -1,16 +0,0 @@ -Clone the version-10 branch of this repo - -```shell -git clone https://github.com/frappe/frappe_docker.git -b version-10 && cd frappe_docker -``` - -Build the images - -```shell -export DOCKER_REGISTRY_PREFIX=frappe -docker build -t ${DOCKER_REGISTRY_PREFIX}/frappe-socketio:v10 -f build/frappe-socketio/Dockerfile . -docker build -t ${DOCKER_REGISTRY_PREFIX}/frappe-nginx:v10 -f build/frappe-nginx/Dockerfile . -docker build -t ${DOCKER_REGISTRY_PREFIX}/erpnext-nginx:v10 -f build/erpnext-nginx/Dockerfile . -docker build -t ${DOCKER_REGISTRY_PREFIX}/frappe-worker:v10 -f build/frappe-worker/Dockerfile . -docker build -t ${DOCKER_REGISTRY_PREFIX}/erpnext-worker:v10 -f build/erpnext-worker/Dockerfile . -``` diff --git a/docs/connect-to-localhost-services-from-containers-for-local-app-development.md b/docs/connect-to-localhost-services-from-containers-for-local-app-development.md deleted file mode 100644 index c76b52a2..00000000 --- a/docs/connect-to-localhost-services-from-containers-for-local-app-development.md +++ /dev/null @@ -1,13 +0,0 @@ -Add following to frappe container from the `.devcontainer/docker-compose.yml`: - -```yaml -... - frappe: - ... - extra_hosts: - app1.localhost: 172.17.0.1 - app2.localhost: 172.17.0.1 -... -``` - -This is makes the domain names `app1.localhost` and `app2.localhost` connect to docker host and connect to services running on `localhost`. diff --git a/docs/custom-apps.md b/docs/custom-apps.md deleted file mode 100644 index c2cb6f85..00000000 --- a/docs/custom-apps.md +++ /dev/null @@ -1,113 +0,0 @@ -### Clone frappe_docker and switch directory - -```shell -git clone https://github.com/frappe/frappe_docker -cd frappe_docker -``` - -### Load custom apps through json - -`apps.json` needs to be passed in as build arg environment variable. - -```shell -export APPS_JSON='[ - { - "url": "https://github.com/frappe/erpnext", - "branch": "version-15" - }, - { - "url": "https://github.com/frappe/payments", - "branch": "version-15" - }, - { - "url": "https://user:password@git.example.com/project/repository.git", - "branch": "main" - } -]' -export APPS_JSON_BASE64=$(echo ${APPS_JSON} | base64 -w 0) -``` - -You can also generate base64 string from json file: - -```shell -export APPS_JSON_BASE64=$(base64 -w 0 /path/to/apps.json) -``` - -Note: - -- `url` needs to be http(s) git url with token/auth in case of private repo. -- add dependencies manually in `apps.json` e.g. add `payments` if you are installing `erpnext` -- use fork repo or branch for ERPNext in case you need to use your fork or test a PR. - -### Build Image - -```shell -buildah build \ - --build-arg=FRAPPE_PATH=https://github.com/frappe/frappe \ - --build-arg=FRAPPE_BRANCH=version-15 \ - --build-arg=PYTHON_VERSION=3.11.6 \ - --build-arg=NODE_VERSION=18.18.2 \ - --build-arg=APPS_JSON_BASE64=$APPS_JSON_BASE64 \ - --tag=ghcr.io/user/repo/custom:1.0.0 \ - --file=images/custom/Containerfile . -``` - -Note: - -- Use `docker` instead of `buildah` as per your setup. -- `FRAPPE_PATH` and `FRAPPE_BRANCH` build args are optional and can be overridden in case of fork/branch or test a PR. -- Make sure `APPS_JSON_BASE64` variable has correct base64 encoded JSON string. It is consumed as build arg, base64 encoding ensures it to be friendly with environment variables. Use `jq empty apps.json` to validate `apps.json` file. -- Make sure the `--tag` is valid image name that will be pushed to registry. See section [below](#use-images) for remarks about its use. -- Change `--build-arg` as per version of Python, NodeJS, Frappe Framework repo and branch -- `.git` directories for all apps are removed from the image. - -### Push image to use in yaml files - -Login to `docker` or `buildah` - -```shell -buildah login -``` - -Push image - -```shell -buildah push ghcr.io/user/repo/custom:1.0.0 -``` - -### Use Kaniko - -Following executor args are required. Example runs locally in docker container. -You can run it part of CI/CD or part of your cluster. - -```shell -podman run --rm -it \ - -v "$HOME"/.docker/config.json:/kaniko/.docker/config.json \ - gcr.io/kaniko-project/executor:latest \ - --dockerfile=images/custom/Containerfile \ - --context=git://github.com/frappe/frappe_docker \ - --build-arg=FRAPPE_PATH=https://github.com/frappe/frappe \ - --build-arg=FRAPPE_BRANCH=version-14 \ - --build-arg=PYTHON_VERSION=3.10.12 \ - --build-arg=NODE_VERSION=16.20.1 \ - --build-arg=APPS_JSON_BASE64=$APPS_JSON_BASE64 \ - --cache=true \ - --destination=ghcr.io/user/repo/custom:1.0.0 \ - --destination=ghcr.io/user/repo/custom:latest -``` - -More about [kaniko](https://github.com/GoogleContainerTools/kaniko) - -### Use Images - -On the [compose.yaml](../compose.yaml) replace the image reference to the `tag` you used when you built it. Then, if you used a tag like `custom_erpnext:staging` the `x-customizable-image` section will look like this: - -``` -x-customizable-image: &customizable_image - image: custom_erpnext:staging - pull_policy: never -``` - -The `pull_policy` above is optional and prevents `docker` to try to download the image when that one has been built locally. - -Make sure image name is correct to be pushed to registry. After the images are pushed, you can pull them to servers to be deployed. If the registry is private, additional auth is needed. diff --git a/docs/development.md b/docs/development.md deleted file mode 100644 index d8813326..00000000 --- a/docs/development.md +++ /dev/null @@ -1,417 +0,0 @@ -# Getting Started - -## Prerequisites - -In order to start developing you need to satisfy the following prerequisites: - -- Docker -- docker-compose -- user added to docker group - -It is recommended you allocate at least 4GB of RAM to docker: - -- [Instructions for Windows](https://docs.docker.com/docker-for-windows/#resources) -- [Instructions for macOS](https://docs.docker.com/desktop/settings/mac/#advanced) - -Here is a screenshot showing the relevant setting in the Help Manual -![image](images/Docker%20Manual%20Screenshot%20-%20Resources%20section.png) -Here is a screenshot showing the settings in Docker Desktop on Mac -![images](images/Docker%20Desktop%20Screenshot%20-%20Resources%20section.png) - -## Bootstrap Containers for development - -Clone and change directory to frappe_docker directory - -```shell -git clone https://github.com/frappe/frappe_docker.git -cd frappe_docker -``` - -Copy example devcontainer config from `devcontainer-example` to `.devcontainer` - -```shell -cp -R devcontainer-example .devcontainer -``` - -Copy example vscode config for devcontainer from `development/vscode-example` to `development/.vscode`. This will setup basic configuration for debugging. - -```shell -cp -R development/vscode-example development/.vscode -``` - -## Use VSCode Remote Containers extension - -For most people getting started with Frappe development, the best solution is to use [VSCode Remote - Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers). - -Before opening the folder in container, determine the database that you want to use. The default is MariaDB. -If you want to use PostgreSQL instead, edit `.devcontainer/docker-compose.yml` and uncomment the section for `postgresql` service, and you may also want to comment `mariadb` as well. - -VSCode should automatically inquire you to install the required extensions, that can also be installed manually as follows: - -- Install Remote - Containers for VSCode - - through command line `code --install-extension ms-vscode-remote.remote-containers` - - clicking on the Install button in the Vistual Studio Marketplace: [Remote - Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) - - View: Extensions command in VSCode (Windows: Ctrl+Shift+X; macOS: Cmd+Shift+X) then search for extension `ms-vscode-remote.remote-containers` - -After the extensions are installed, you can: - -- Open frappe_docker folder in VS Code. - - `code .` -- Launch the command, from Command Palette (Ctrl + Shift + P) `Remote-Containers: Reopen in Container`. You can also click in the bottom left corner to access the remote container menu. - -Notes: - -- The `development` directory is ignored by git. It is mounted and available inside the container. Create all your benches (installations of bench, the tool that manages frappe) inside this directory. -- Node v14 and v10 are installed. Check with `nvm ls`. Node v14 is used by default. - -### Setup first bench - -> Jump to [scripts](#setup-bench--new-site-using-script) section to setup a bench automatically. Alternatively, you can setup a bench manually using below guide. - -Run the following commands in the terminal inside the container. You might need to create a new terminal in VSCode. - -NOTE: Prior to doing the following, make sure the user is **frappe**. - -```shell -bench init --skip-redis-config-generation frappe-bench -cd frappe-bench -``` - -To setup frappe framework version 14 bench set `PYENV_VERSION` environment variable to `3.10.5` (default) and use NodeJS version 16 (default), - -```shell -# Use default environments -bench init --skip-redis-config-generation --frappe-branch version-14 frappe-bench -# Or set environment versions explicitly -nvm use v16 -PYENV_VERSION=3.10.13 bench init --skip-redis-config-generation --frappe-branch version-14 frappe-bench -# Switch directory -cd frappe-bench -``` - -To setup frappe framework version 13 bench set `PYENV_VERSION` environment variable to `3.9.17` and use NodeJS version 14, - -```shell -nvm use v14 -PYENV_VERSION=3.9.17 bench init --skip-redis-config-generation --frappe-branch version-13 frappe-bench -cd frappe-bench -``` - -### Setup hosts - -We need to tell bench to use the right containers instead of localhost. Run the following commands inside the container: - -```shell -bench set-config -g db_host mariadb -bench set-config -g redis_cache redis://redis-cache:6379 -bench set-config -g redis_queue redis://redis-queue:6379 -bench set-config -g redis_socketio redis://redis-queue:6379 -``` - -For any reason the above commands fail, set the values in `common_site_config.json` manually. - -```json -{ - "db_host": "mariadb", - "redis_cache": "redis://redis-cache:6379", - "redis_queue": "redis://redis-queue:6379", - "redis_socketio": "redis://redis-queue:6379" -} -``` - -### Edit Honcho's Procfile - -Note : With the option '--skip-redis-config-generation' during bench init, these actions are no more needed. But at least, take a look to ProcFile to see what going on when bench launch honcho on start command - -Honcho is the tool used by Bench to manage all the processes Frappe requires. Usually, these all run in localhost, but in this case, we have external containers for Redis. For this reason, we have to stop Honcho from trying to start Redis processes. - -Honcho is installed in global python environment along with bench. To make it available locally you've to install it in every `frappe-bench/env` you create. Install it using command `./env/bin/pip install honcho`. It is required locally if you wish to use is as part of VSCode launch configuration. - -Open the Procfile file and remove the three lines containing the configuration from Redis, either by editing manually the file: - -```shell -code Procfile -``` - -Or running the following command: - -```shell -sed -i '/redis/d' ./Procfile -``` - -### Create a new site with bench - -You can create a new site with the following command: - -```shell -bench new-site --no-mariadb-socket sitename -``` - -sitename MUST end with .localhost for trying deployments locally. - -for example: - -```shell -bench new-site --no-mariadb-socket development.localhost -``` - -The same command can be run non-interactively as well: - -```shell -bench new-site --mariadb-root-password 123 --admin-password admin --no-mariadb-socket development.localhost -``` - -The command will ask the MariaDB root password. The default root password is `123`. -This will create a new site and a `development.localhost` directory under `frappe-bench/sites`. -The option `--no-mariadb-socket` will configure site's database credentials to work with docker. -You may need to configure your system /etc/hosts if you're on Linux, Mac, or its Windows equivalent. - -To setup site with PostgreSQL as database use option `--db-type postgres` and `--db-host postgresql`. (Available only v12 onwards, currently NOT available for ERPNext). - -Example: - -```shell -bench new-site --db-type postgres --db-host postgresql mypgsql.localhost -``` - -To avoid entering postgresql username and root password, set it in `common_site_config.json`, - -```shell -bench config set-common-config -c root_login postgres -bench config set-common-config -c root_password '"123"' -``` - -Note: If PostgreSQL is not required, the postgresql service / container can be stopped. - -### Set bench developer mode on the new site - -To develop a new app, the last step will be setting the site into developer mode. Documentation is available at [this link](https://frappe.io/docs/user/en/guides/app-development/how-enable-developer-mode-in-frappe). - -```shell -bench --site development.localhost set-config developer_mode 1 -bench --site development.localhost clear-cache -``` - -### Install an app - -To install an app we need to fetch it from the appropriate git repo, then install in on the appropriate site: - -You can check [VSCode container remote extension documentation](https://code.visualstudio.com/docs/remote/containers#_sharing-git-credentials-with-your-container) regarding git credential sharing. - -To install custom app - -```shell -# --branch is optional, use it to point to branch on custom app repository -bench get-app --branch version-12 https://github.com/myusername/myapp -bench --site development.localhost install-app myapp -``` - -At the time of this writing, the Payments app has been factored out of the Version 14 ERPNext app and is now a separate app. ERPNext will not install it. - -```shell -bench get-app --branch version-14 --resolve-deps erpnext -bench --site development.localhost install-app erpnext -``` - -To install ERPNext (from the version-13 branch): - -```shell -bench get-app --branch version-13 erpnext -bench --site development.localhost install-app erpnext -``` - -Note: Both frappe and erpnext must be on branch with same name. e.g. version-14 - -### Start Frappe without debugging - -Execute following command from the `frappe-bench` directory. - -```shell -bench start -``` - -You can now login with user `Administrator` and the password you choose when creating the site. -Your website will now be accessible at location [development.localhost:8000](http://development.localhost:8000) -Note: To start bench with debugger refer section for debugging. - -### Setup bench / new site using script - -Most developers work with numerous clients and versions. Moreover, apps may be required to be installed by everyone on the team working for a client. - -This is simplified using a script to automate the process of creating a new bench / site and installing the required apps. `Administrator` password is for created sites is `admin`. - -Sample `apps-example.json` is used by default, it installs erpnext on current stable release. To install custom apps, copy the `apps-example.json` to custom json file and make changes to list of apps. Pass this file to the `installer.py` script. - -> You may have apps in private repos which may require ssh access. You may use SSH from your home directory on linux (configurable in docker-compose.yml). - -```shell -python installer.py #pass --db-type postgres for postgresdb -``` - -For command help - -```shell -python installer.py --help -usage: installer.py [-h] [-j APPS_JSON] [-b BENCH_NAME] [-s SITE_NAME] [-r FRAPPE_REPO] [-t FRAPPE_BRANCH] [-p PY_VERSION] [-n NODE_VERSION] [-v] [-a ADMIN_PASSWORD] [-d DB_TYPE] - -options: - -h, --help show this help message and exit - -j APPS_JSON, --apps-json APPS_JSON - Path to apps.json, default: apps-example.json - -b BENCH_NAME, --bench-name BENCH_NAME - Bench directory name, default: frappe-bench - -s SITE_NAME, --site-name SITE_NAME - Site name, should end with .localhost, default: development.localhost - -r FRAPPE_REPO, --frappe-repo FRAPPE_REPO - frappe repo to use, default: https://github.com/frappe/frappe - -t FRAPPE_BRANCH, --frappe-branch FRAPPE_BRANCH - frappe repo to use, default: version-15 - -p PY_VERSION, --py-version PY_VERSION - python version, default: Not Set - -n NODE_VERSION, --node-version NODE_VERSION - node version, default: Not Set - -v, --verbose verbose output - -a ADMIN_PASSWORD, --admin-password ADMIN_PASSWORD - admin password for site, default: admin - -d DB_TYPE, --db-type DB_TYPE - Database type to use (e.g., mariadb or postgres) -``` - -A new bench and / or site is created for the client with following defaults. - -- MariaDB root password: `123` -- Admin password: `admin` - -> To use Postegres DB, comment the mariabdb service and uncomment postegres service. - -### Start Frappe with Visual Studio Code Python Debugging - -To enable Python debugging inside Visual Studio Code, you must first install the `ms-python.python` extension inside the container. This should have already happened automatically, but depending on your VSCode config, you can force it by: - -- Click on the extension icon inside VSCode -- Search `ms-python.python` -- Click on `Install on Dev Container: Frappe Bench` -- Click on 'Reload' - -We need to start bench separately through the VSCode debugger. For this reason, **instead** of running `bench start` you should run the following command inside the frappe-bench directory: - -```shell -honcho start \ - socketio \ - watch \ - schedule \ - worker_short \ - worker_long -``` - -Alternatively you can use the VSCode launch configuration "Honcho SocketIO Watch Schedule Worker" which launches the same command as above. - -This command starts all processes with the exception of Redis (which is already running in separate container) and the `web` process. The latter can can finally be started from the debugger tab of VSCode by clicking on the "play" button. - -You can now login with user `Administrator` and the password you choose when creating the site, if you followed this guide's unattended install that password is going to be `admin`. - -To debug workers, skip starting worker with honcho and start it with VSCode debugger. - -For advance vscode configuration in the devcontainer, change the config files in `development/.vscode`. - -## Developing using the interactive console - -You can launch a simple interactive shell console in the terminal with: - -```shell -bench --site development.localhost console -``` - -More likely, you may want to launch VSCode interactive console based on Jupyter kernel. - -Launch VSCode command palette (cmd+shift+p or ctrl+shift+p), run the command `Python: Select interpreter to start Jupyter server` and select `/workspace/development/frappe-bench/env/bin/python`. - -The first step is installing and updating the required software. Usually the frappe framework may require an older version of Jupyter, while VSCode likes to move fast, this can [cause issues](https://github.com/jupyter/jupyter_console/issues/158). For this reason we need to run the following command. - -```shell -/workspace/development/frappe-bench/env/bin/python -m pip install --upgrade jupyter ipykernel ipython -``` - -Then, run the command `Python: Show Python interactive window` from the VSCode command palette. - -Replace `development.localhost` with your site and run the following code in a Jupyter cell: - -```python -import frappe - -frappe.init(site='development.localhost', sites_path='/workspace/development/frappe-bench/sites') -frappe.connect() -frappe.local.lang = frappe.db.get_default('lang') -frappe.db.connect() -``` - -The first command can take a few seconds to be executed, this is to be expected. - -## Manually start containers - -In case you don't use VSCode, you may start the containers manually with the following command: - -### Running the containers - -```shell -docker-compose -f .devcontainer/docker-compose.yml up -d -``` - -And enter the interactive shell for the development container with the following command: - -```shell -docker exec -e "TERM=xterm-256color" -w /workspace/development -it devcontainer-frappe-1 bash -``` - -## Use additional services during development - -Add any service that is needed for development in the `.devcontainer/docker-compose.yml` then rebuild and reopen in devcontainer. - -e.g. - -```yaml -... -services: - ... - postgresql: - image: postgres:11.8 - environment: - POSTGRES_PASSWORD: 123 - volumes: - - postgresql-data:/var/lib/postgresql/data - ports: - - 5432:5432 - -volumes: - ... - postgresql-data: -``` - -Access the service by service name from the `frappe` development container. The above service will be accessible via hostname `postgresql`. If ports are published on to host, access it via `localhost:5432`. - -## Using Cypress UI tests - -To run cypress based UI tests in a docker environment, follow the below steps: - -1. Install and setup X11 tooling on VM using the script `install_x11_deps.sh` - -```shell - sudo bash ./install_x11_deps.sh -``` - -This script will install required deps, enable X11Forwarding and restart SSH daemon and export `DISPLAY` variable. - -2. Run X11 service `startx` or `xquartz` -3. Start docker compose services. -4. SSH into ui-tester service using `docker exec..` command -5. Export CYPRESS_baseUrl and other required env variables -6. Start Cypress UI console by issuing `cypress run command` - -> More references : [Cypress Official Documentation](https://www.cypress.io/blog/2019/05/02/run-cypress-with-a-single-docker-command) - -> Ensure DISPLAY environment is always exported. - -## Using Mailpit to test mail services - -To use Mailpit just uncomment the service in the docker-compose.yml file. -The Interface is then available under port 8025 and the smtp service can be used as mailpit:1025. diff --git a/docs/environment-variables.md b/docs/environment-variables.md deleted file mode 100644 index 0101fcbd..00000000 --- a/docs/environment-variables.md +++ /dev/null @@ -1,56 +0,0 @@ -## Environment Variables - -All of the commands are directly passed to container as per type of service. Only environment variables used in image are for `nginx-entrypoint.sh` command. They are as follows: - -- `BACKEND`: Set to `{host}:{port}`, defaults to `0.0.0.0:8000` -- `SOCKETIO`: Set to `{host}:{port}`, defaults to `0.0.0.0:9000` -- `UPSTREAM_REAL_IP_ADDRESS`: Set Nginx config for [ngx_http_realip_module#set_real_ip_from](http://nginx.org/en/docs/http/ngx_http_realip_module.html#set_real_ip_from), defaults to `127.0.0.1` -- `UPSTREAM_REAL_IP_HEADER`: Set Nginx config for [ngx_http_realip_module#real_ip_header](http://nginx.org/en/docs/http/ngx_http_realip_module.html#real_ip_header), defaults to `X-Forwarded-For` -- `UPSTREAM_REAL_IP_RECURSIVE`: Set Nginx config for [ngx_http_realip_module#real_ip_recursive](http://nginx.org/en/docs/http/ngx_http_realip_module.html#real_ip_recursive) Set defaults to `off` -- `FRAPPE_SITE_NAME_HEADER`: Set proxy header `X-Frappe-Site-Name` and serve site named in the header, defaults to `$host`, i.e. find site name from host header. More details [below](#frappe_site_name_header) -- `PROXY_READ_TIMEOUT`: Upstream gunicorn service timeout, defaults to `120` -- `CLIENT_MAX_BODY_SIZE`: Max body size for uploads, defaults to `50m` - -To bypass `nginx-entrypoint.sh`, mount desired `/etc/nginx/conf.d/default.conf` and run `nginx -g 'daemon off;'` as container command. - -## Configuration - -We use environment variables to configure our setup. docker-compose uses variables from `.env` file. To get started, copy `example.env` to `.env`. - -### `FRAPPE_VERSION` - -Frappe framework release. You can find all releases [here](https://github.com/frappe/frappe/releases). - -### `DB_PASSWORD` - -Password for MariaDB (or Postgres) database. - -### `DB_HOST` - -Hostname for MariaDB (or Postgres) database. Set only if external service for database is used. - -### `DB_PORT` - -Port for MariaDB (3306) or Postgres (5432) database. Set only if external service for database is used. - -### `REDIS_CACHE` - -Hostname for redis server to store cache. Set only if external service for redis is used. - -### `REDIS_QUEUE` - -Hostname for redis server to store queue data and socketio. Set only if external service for redis is used. - -### `ERPNEXT_VERSION` - -ERPNext [release](https://github.com/frappe/frappe/releases). This variable is required if you use ERPNext override. - -### `LETSENCRYPT_EMAIL` - -Email that used to register https certificate. This one is required only if you use HTTPS override. - -### `FRAPPE_SITE_NAME_HEADER` - -This environment variable is not required. Default value is `$$host` which resolves site by host. For example, if your host is `example.com`, site's name should be `example.com`, or if host is `127.0.0.1` (local debugging), it should be `127.0.0.1` This variable allows to override described behavior. Let's say you create site named `mysite` and do want to access it by `127.0.0.1` host. Than you would set this variable to `mysite`. - -There is other variables not mentioned here. They're somewhat internal and you don't have to worry about them except you want to change main compose file. diff --git a/docs/images/Docker Desktop Screenshot - Resources section.png b/docs/images/Docker Desktop Screenshot - Resources section.png deleted file mode 100644 index 7efe2e67b539baabc0a6bed23e8ae1fc9938fd21..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32144 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 DyAsbV diff --git a/docs/list-of-containers.md b/docs/list-of-containers.md deleted file mode 100644 index 4ccf66ee..00000000 --- a/docs/list-of-containers.md +++ /dev/null @@ -1,58 +0,0 @@ -# Images - -There are 3 images that you can find in `/images` directory: - -- `bench`. It is used for development. [Learn more how to start development](../development/README.md). -- `production`. - - Multi-purpose Python backend. Runs [Werkzeug server](https://werkzeug.palletsprojects.com/en/2.0.x/) with [gunicorn](https://gunicorn.org), queues (via `bench worker`), or schedule (via `bench schedule`). - - Contains JS and CSS assets and routes incoming requests using [nginx](https://www.nginx.com). - - Processes realtime websocket requests using [Socket.IO](https://socket.io). -- `custom`. It is used to build bench using `apps.json` file set with `--apps_path` during bench initialization. `apps.json` is a json array. e.g. `[{"url":"{{repo_url}}","branch":"{{repo_branch}}"}]` - -Image has everything we need to be able to run all processes that Frappe framework requires (take a look at [Bench Procfile reference](https://frappeframework.com/docs/v14/user/en/bench/resources/bench-procfile)). We follow [Docker best practices](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#decouple-applications) and split these processes to different containers. - -> We use [multi-stage builds](https://docs.docker.com/develop/develop-images/multistage-build/) and [Docker Buildx](https://docs.docker.com/engine/reference/commandline/buildx/) to reuse as much things as possible and make our builds more efficient. - -# Compose files - -After building the images we have to run the containers. The best and simplest way to do this is to use [compose files](https://docs.docker.com/compose/compose-file/). - -We have one main compose file, `compose.yaml`. Services described, networking, volumes are also handled there. - -## Services - -All services are described in `compose.yaml` - -- `configurator`. Updates `common_site_config.json` so Frappe knows how to access db and redis. It is executed on every `docker-compose up` (and exited immediately). Other services start after this container exits successfully. -- `backend`. [Werkzeug server](https://werkzeug.palletsprojects.com/en/2.0.x/). -- `db`. Optional service that runs [MariaDB](https://mariadb.com) if you also use `overrides/compose.mariadb.yaml` or [Postgres](https://www.postgresql.org) if you also use `overrides/compose.postgres.yaml`. -- `redis`. Optional service that runs [Redis](https://redis.io) server with cache, [Socket.IO](https://socket.io) and queues data. -- `frontend`. [nginx](https://www.nginx.com) server that serves JS/CSS assets and routes incoming requests. -- `proxy`. [Traefik](https://traefik.io/traefik/) proxy. It is here for complicated setups or HTTPS override (with `overrides/compose.https.yaml`). -- `websocket`. Node server that runs [Socket.IO](https://socket.io). -- `queue-short`, `queue-long`. Python servers that run job queues using [rq](https://python-rq.org). -- `scheduler`. Python server that runs tasks on schedule using [schedule](https://schedule.readthedocs.io/en/stable/). - -## Overrides - -We have several [overrides](https://docs.docker.com/compose/extends/): - -- `overrides/compose.proxy.yaml`. Adds traefik proxy to setup. -- `overrides/compose.noproxy.yaml`. Publishes `frontend` ports directly without any proxy. -- `overrides/compose.https.yaml`. Automatically sets up Let's Encrypt certificate and redirects all requests to directed to http, to https. -- `overrides/compose.mariadb.yaml`. Adds `db` service and sets its image to MariaDB. -- `overrides/compose.postgres.yaml`. Adds `db` service and sets its image to Postgres. Note that ERPNext currently doesn't support Postgres. -- `overrides/compose.redis.yaml`. Adds `redis` service and sets its image to `redis`. - -It is quite simple to run overrides. All we need to do is to specify compose files that should be used by docker-compose. For example, we want ERPNext: - -```bash -# Point to main compose file (compose.yaml) and add one more. -docker-compose -f compose.yaml -f overrides/compose.redis.yaml config -``` - -⚠ Make sure to use docker-compose v2 (run `docker-compose -v` to check). If you want to use v1 make sure the correct `$`-signs as they get duplicated by the `config` command! - -That's it! Of course, we also have to setup `.env` before all of that, but that's not the point. - -Check [environment variables](environment-variables.md) for more. diff --git a/docs/migrate-from-multi-image-setup.md b/docs/migrate-from-multi-image-setup.md deleted file mode 100644 index 79072b0d..00000000 --- a/docs/migrate-from-multi-image-setup.md +++ /dev/null @@ -1,112 +0,0 @@ -## Migrate from multi-image setup - -All the containers now use same image. Use `frappe/erpnext` instead of `frappe/frappe-worker`, `frappe/frappe-nginx` , `frappe/frappe-socketio` , `frappe/erpnext-worker` and `frappe/erpnext-nginx`. - -Now you need to specify command and environment variables for following containers: - -### Frontend - -For `frontend` service to act as static assets frontend and reverse proxy, you need to pass `nginx-entrypoint.sh` as container `command` and `BACKEND` and `SOCKETIO` environment variables pointing `{host}:{port}` for gunicorn and websocket services. Check [environment variables](environment-variables.md) - -Now you only need to mount the `sites` volume at location `/home/frappe/frappe-bench/sites`. No need for `assets` volume and asset population script or steps. - -Example change: - -```yaml -# ... removed for brevity -frontend: - image: frappe/erpnext:${ERPNEXT_VERSION:?ERPNext version not set} - command: - - nginx-entrypoint.sh - environment: - BACKEND: backend:8000 - SOCKETIO: websocket:9000 - volumes: - - sites:/home/frappe/frappe-bench/sites -# ... removed for brevity -``` - -### Websocket - -For `websocket` service to act as socketio backend, you need to pass `["node", "/home/frappe/frappe-bench/apps/frappe/socketio.js"]` as container `command` - -Example change: - -```yaml -# ... removed for brevity -websocket: - image: frappe/erpnext:${ERPNEXT_VERSION:?ERPNext version not set} - command: - - node - - /home/frappe/frappe-bench/apps/frappe/socketio.js -# ... removed for brevity -``` - -### Configurator - -For `configurator` service to act as run once configuration job, you need to pass `["bash", "-c"]` as container `entrypoint` and bash script inline to yaml. There is no `configure.py` in the container now. - -Example change: - -```yaml -# ... removed for brevity -configurator: - image: frappe/erpnext:${ERPNEXT_VERSION:?ERPNext version not set} - restart: "no" - entrypoint: - - bash - - -c - command: - - > - bench set-config -g db_host $$DB_HOST; - bench set-config -gp db_port $$DB_PORT; - bench set-config -g redis_cache "redis://$$REDIS_CACHE"; - bench set-config -g redis_queue "redis://$$REDIS_QUEUE"; - bench set-config -gp socketio_port $$SOCKETIO_PORT; - environment: - DB_HOST: db - DB_PORT: "3306" - REDIS_CACHE: redis-cache:6379 - REDIS_QUEUE: redis-queue:6379 - SOCKETIO_PORT: "9000" -# ... removed for brevity -``` - -### Site Creation - -For `create-site` service to act as run once site creation job, you need to pass `["bash", "-c"]` as container `entrypoint` and bash script inline to yaml. Make sure to use `--no-mariadb-socket` as upstream bench is installed in container. - -The `WORKDIR` has changed to `/home/frappe/frappe-bench` like `bench` setup we are used to. So the path to find `common_site_config.json` has changed to `sites/common_site_config.json`. - -Example change: - -```yaml -# ... removed for brevity -create-site: - image: frappe/erpnext:${ERPNEXT_VERSION:?ERPNext version not set} - restart: "no" - entrypoint: - - bash - - -c - command: - - > - wait-for-it -t 120 db:3306; - wait-for-it -t 120 redis-cache:6379; - wait-for-it -t 120 redis-queue:6379; - export start=`date +%s`; - until [[ -n `grep -hs ^ sites/common_site_config.json | jq -r ".db_host // empty"` ]] && \ - [[ -n `grep -hs ^ sites/common_site_config.json | jq -r ".redis_cache // empty"` ]] && \ - [[ -n `grep -hs ^ sites/common_site_config.json | jq -r ".redis_queue // empty"` ]]; - do - echo "Waiting for sites/common_site_config.json to be created"; - sleep 5; - if (( `date +%s`-start > 120 )); then - echo "could not find sites/common_site_config.json with required keys"; - exit 1 - fi - done; - echo "sites/common_site_config.json found"; - bench new-site --no-mariadb-socket --admin-password=admin --db-root-password=admin --install-app erpnext --set-default frontend; - -# ... removed for brevity -``` diff --git a/docs/port-based-multi-tenancy.md b/docs/port-based-multi-tenancy.md deleted file mode 100644 index 2f469bfd..00000000 --- a/docs/port-based-multi-tenancy.md +++ /dev/null @@ -1,69 +0,0 @@ -WARNING: Do not use this in production if the site is going to be served over plain http. - -### Step 1 - -Remove the traefik service from docker-compose.yml - -### Step 2 - -Add service for each port that needs to be exposed. - -e.g. `port-site-1`, `port-site-2`, `port-site-3`. - -```yaml -# ... removed for brevity -services: - # ... removed for brevity - port-site-1: - image: frappe/erpnext:v14.11.1 - deploy: - restart_policy: - condition: on-failure - command: - - nginx-entrypoint.sh - environment: - BACKEND: backend:8000 - FRAPPE_SITE_NAME_HEADER: site1.local - SOCKETIO: websocket:9000 - volumes: - - sites:/home/frappe/frappe-bench/sites - ports: - - "8080:8080" - port-site-2: - image: frappe/erpnext:v14.11.1 - deploy: - restart_policy: - condition: on-failure - command: - - nginx-entrypoint.sh - environment: - BACKEND: backend:8000 - FRAPPE_SITE_NAME_HEADER: site2.local - SOCKETIO: websocket:9000 - volumes: - - sites:/home/frappe/frappe-bench/sites - ports: - - "8081:8080" - port-site-3: - image: frappe/erpnext:v14.11.1 - deploy: - restart_policy: - condition: on-failure - command: - - nginx-entrypoint.sh - environment: - BACKEND: backend:8000 - FRAPPE_SITE_NAME_HEADER: site3.local - SOCKETIO: websocket:9000 - volumes: - - sites:/home/frappe/frappe-bench/sites - ports: - - "8082:8080" -``` - -Notes: - -- Above setup will expose `site1.local`, `site2.local`, `site3.local` on port `8080`, `8081`, `8082` respectively. -- Change `site1.local` to site name to serve from bench. -- Change the `BACKEND` and `SOCKETIO` environment variables as per your service names. -- Make sure `sites:` volume is available as part of yaml. diff --git a/docs/setup-options.md b/docs/setup-options.md deleted file mode 100644 index 1d49ceea..00000000 --- a/docs/setup-options.md +++ /dev/null @@ -1,131 +0,0 @@ -# Containerized Production Setup - -Make sure you've cloned this repository and switch to the directory before executing following commands. - -Commands will generate YAML as per the environment for setup. - -## Prerequisites - -- [docker](https://docker.com/get-started) -- [docker compose v2](https://docs.docker.com/compose/cli-command) - -## Setup Environment Variables - -Copy the example docker environment file to `.env`: - -```sh -cp example.env .env -``` - -Note: To know more about environment variable [read here](./environment-variables.md). Set the necessary variables in the `.env` file. - -## Generate docker-compose.yml for variety of setups - -Notes: - -- Make sure to replace `` with the desired name you wish to set for the project. -- This setup is not to be used for development. A complete development environment is available [here](../development) - -### Store the yaml files - -YAML files generated by `docker compose config` command can be stored in a directory. We will create a directory called `gitops` in the user's home. - -```shell -mkdir ~/gitops -``` - -You can make the directory into a private git repo which stores the yaml and secrets. It can help in tracking changes. - -Instead of `docker compose config`, you can directly use `docker compose up` to start the containers and skip storing the yamls in `gitops` directory. - -### Setup Frappe without proxy and external MariaDB and Redis - -In this case make sure you've set `DB_HOST`, `DB_PORT`, `REDIS_CACHE` and `REDIS_QUEUE` environment variables or the `configurator` will fail. - -```sh -# Generate YAML -docker compose -f compose.yaml -f overrides/compose.noproxy.yaml config > ~/gitops/docker-compose.yml - -# Start containers -docker compose --project-name -f ~/gitops/docker-compose.yml up -d -``` - -### Setup ERPNext with proxy and external MariaDB and Redis - -In this case make sure you've set `DB_HOST`, `DB_PORT`, `REDIS_CACHE` and `REDIS_QUEUE` environment variables or the `configurator` will fail. - -```sh -# Generate YAML -docker compose -f compose.yaml \ - -f overrides/compose.proxy.yaml \ - config > ~/gitops/docker-compose.yml - -# Start containers -docker compose --project-name -f ~/gitops/docker-compose.yml up -d -``` - -### Setup Frappe using containerized MariaDB and Redis with Letsencrypt certificates. - -In this case make sure you've set `LETSENCRYPT_EMAIL` and `SITES` environment variables are set or certificates won't work. - -```sh -# Generate YAML -docker compose -f compose.yaml \ - -f overrides/compose.mariadb.yaml \ - -f overrides/compose.redis.yaml \ - -f overrides/compose.https.yaml \ - config > ~/gitops/docker-compose.yml - -# Start containers -docker compose --project-name -f ~/gitops/docker-compose.yml up -d -``` - -### Setup ERPNext using containerized MariaDB and Redis with Letsencrypt certificates. - -In this case make sure you've set `LETSENCRYPT_EMAIL` and `SITES` environment variables are set or certificates won't work. - -```sh -# Generate YAML -docker compose -f compose.yaml \ - -f overrides/compose.mariadb.yaml \ - -f overrides/compose.redis.yaml \ - -f overrides/compose.https.yaml \ - config > ~/gitops/docker-compose.yml - -# Start containers -docker compose --project-name -f ~/gitops/docker-compose.yml up -d -``` - -## Create first site - -After starting containers, the first site needs to be created. Refer [site operations](./site-operations.md#setup-new-site). - -## Updating Images - -Switch to the root of the `frappe_docker` directory before running the following commands: - -```sh -# Update environment variables ERPNEXT_VERSION and FRAPPE_VERSION -nano .env - -# Pull new images -docker compose -f compose.yaml \ - # ... your other overrides - config > ~/gitops/docker-compose.yml - -# Pull images -docker compose --project-name -f ~/gitops/docker-compose.yml pull - -# Stop containers -docker compose --project-name -f ~/gitops/docker-compose.yml down - -# Restart containers -docker compose --project-name -f ~/gitops/docker-compose.yml up -d -``` - -Note: - -- pull and stop container commands can be skipped if immutable image tags are used -- `docker compose up -d` will pull new immutable tags if not found. - -To migrate sites refer [site operations](./site-operations.md#migrate-site) diff --git a/docs/setup_for_linux_mac.md b/docs/setup_for_linux_mac.md deleted file mode 100644 index 651f67ca..00000000 --- a/docs/setup_for_linux_mac.md +++ /dev/null @@ -1,225 +0,0 @@ -# How to install ERPNext on linux/mac using Frappe_docker ? - -step1: clone the repo - -``` -git clone https://github.com/frappe/frappe_docker -``` - -step2: add platform: linux/amd64 to all services in the /pwd.yaml - -here is the update pwd.yml file - -```yml -version: "3" - -services: - backend: - image: frappe/erpnext:v14 - platform: linux/amd64 - deploy: - restart_policy: - condition: on-failure - volumes: - - sites:/home/frappe/frappe-bench/sites - - logs:/home/frappe/frappe-bench/logs - - configurator: - image: frappe/erpnext:v14 - platform: linux/amd64 - deploy: - restart_policy: - condition: none - entrypoint: - - bash - - -c - command: - - > - ls -1 apps > sites/apps.txt; - bench set-config -g db_host $$DB_HOST; - bench set-config -gp db_port $$DB_PORT; - bench set-config -g redis_cache "redis://$$REDIS_CACHE"; - bench set-config -g redis_queue "redis://$$REDIS_QUEUE"; - bench set-config -gp socketio_port $$SOCKETIO_PORT; - environment: - DB_HOST: db - DB_PORT: "3306" - REDIS_CACHE: redis-cache:6379 - REDIS_QUEUE: redis-queue:6379 - SOCKETIO_PORT: "9000" - volumes: - - sites:/home/frappe/frappe-bench/sites - - logs:/home/frappe/frappe-bench/logs - - create-site: - image: frappe/erpnext:v14 - platform: linux/amd64 - deploy: - restart_policy: - condition: none - volumes: - - sites:/home/frappe/frappe-bench/sites - - logs:/home/frappe/frappe-bench/logs - entrypoint: - - bash - - -c - command: - - > - wait-for-it -t 120 db:3306; - wait-for-it -t 120 redis-cache:6379; - wait-for-it -t 120 redis-queue:6379; - export start=`date +%s`; - until [[ -n `grep -hs ^ sites/common_site_config.json | jq -r ".db_host // empty"` ]] && \ - [[ -n `grep -hs ^ sites/common_site_config.json | jq -r ".redis_cache // empty"` ]] && \ - [[ -n `grep -hs ^ sites/common_site_config.json | jq -r ".redis_queue // empty"` ]]; - do - echo "Waiting for sites/common_site_config.json to be created"; - sleep 5; - if (( `date +%s`-start > 120 )); then - echo "could not find sites/common_site_config.json with required keys"; - exit 1 - fi - done; - echo "sites/common_site_config.json found"; - bench new-site --no-mariadb-socket --admin-password=admin --db-root-password=admin --install-app erpnext --set-default frontend; - - db: - image: mariadb:10.6 - platform: linux/amd64 - healthcheck: - test: mysqladmin ping -h localhost --password=admin - interval: 1s - retries: 15 - deploy: - restart_policy: - condition: on-failure - command: - - --character-set-server=utf8mb4 - - --collation-server=utf8mb4_unicode_ci - - --skip-character-set-client-handshake - - --skip-innodb-read-only-compressed # Temporary fix for MariaDB 10.6 - environment: - MYSQL_ROOT_PASSWORD: admin - volumes: - - db-data:/var/lib/mysql - - frontend: - image: frappe/erpnext:v14 - platform: linux/amd64 - deploy: - restart_policy: - condition: on-failure - command: - - nginx-entrypoint.sh - environment: - BACKEND: backend:8000 - FRAPPE_SITE_NAME_HEADER: frontend - SOCKETIO: websocket:9000 - UPSTREAM_REAL_IP_ADDRESS: 127.0.0.1 - UPSTREAM_REAL_IP_HEADER: X-Forwarded-For - UPSTREAM_REAL_IP_RECURSIVE: "off" - PROXY_READ_TIMEOUT: 120 - CLIENT_MAX_BODY_SIZE: 50m - volumes: - - sites:/home/frappe/frappe-bench/sites - - logs:/home/frappe/frappe-bench/logs - ports: - - "8080:8080" - - queue-long: - image: frappe/erpnext:v14 - platform: linux/amd64 - deploy: - restart_policy: - condition: on-failure - command: - - bench - - worker - - --queue - - long - volumes: - - sites:/home/frappe/frappe-bench/sites - - logs:/home/frappe/frappe-bench/logs - - queue-short: - image: frappe/erpnext:v14 - platform: linux/amd64 - deploy: - restart_policy: - condition: on-failure - command: - - bench - - worker - - --queue - - short - volumes: - - sites:/home/frappe/frappe-bench/sites - - logs:/home/frappe/frappe-bench/logs - - redis-queue: - image: redis:6.2-alpine - platform: linux/amd64 - deploy: - restart_policy: - condition: on-failure - volumes: - - redis-queue-data:/data - - redis-cache: - image: redis:6.2-alpine - platform: linux/amd64 - deploy: - restart_policy: - condition: on-failure - volumes: - - redis-cache-data:/data - - scheduler: - image: frappe/erpnext:v14 - platform: linux/amd64 - deploy: - restart_policy: - condition: on-failure - command: - - bench - - schedule - volumes: - - sites:/home/frappe/frappe-bench/sites - - logs:/home/frappe/frappe-bench/logs - - websocket: - image: frappe/erpnext:v14 - platform: linux/amd64 - deploy: - restart_policy: - condition: on-failure - command: - - node - - /home/frappe/frappe-bench/apps/frappe/socketio.js - volumes: - - sites:/home/frappe/frappe-bench/sites - - logs:/home/frappe/frappe-bench/logs - -volumes: - db-data: - redis-queue-data: - redis-cache-data: - sites: - logs: -``` - -step3: run the docker - -``` -cd frappe_docker -``` - -``` -docker-compose -f ./pwd.yml up -``` - ---- - -Wait for couple of minutes. - -Open localhost:8080 diff --git a/docs/single-compose-setup.md b/docs/single-compose-setup.md deleted file mode 100644 index aea2914c..00000000 --- a/docs/single-compose-setup.md +++ /dev/null @@ -1,38 +0,0 @@ -# Single Compose Setup - -This setup is a very simple single compose file that does everything to start required services and a frappe-bench. It is used to start play with docker instance with a site. The file is located in the root of repo and named `pwd.yml`. - -## Services - -### frappe-bench components - -- backend, serves gunicorn backend -- frontend, serves static assets through nginx frontend reverse proxies websocket and gunicorn. -- queue-long, long default and short rq worker. -- queue-short, default and short rq worker. -- schedule, event scheduler. -- websocket, socketio websocket for realtime communication. - -### Run once configuration - -- configurator, configures `common_site_config.json` to set db and redis hosts. -- create-site, creates one site to serve as default site for the frappe-bench. - -### Service dependencies - -- db, mariadb, container with frappe specific configuration. -- redis-cache, redis for cache data. -- redis-queue, redis for rq data and pub/sub. - -## Volumes - -- sites: Volume for bench data. Common config, all sites, all site configs and site files will be stored here. -- logs: Volume for bench logs. all process logs are dumped here. No need to mount it. Each container will create a temporary volume for logs if not specified. - -## Adaptation - -If you understand containers use the `pwd.yml` as a reference to build more complex setup like, single server example, Docker Swarm stack, Kubernetes Helm chart, etc. - -This serves only site called `frontend` through the nginx. `FRAPPE_SITE_NAME_HEADER` is set to `frontend` and a default site called `frontend` is created. - -Change the `$$host` will allow container to accept any host header and serve that site. To escape `$` in compose yaml use it like `$$`. To unset default site remove `currentsite.txt` file from `sites` directory. diff --git a/docs/single-server-example.md b/docs/single-server-example.md deleted file mode 100644 index 35cf54d7..00000000 --- a/docs/single-server-example.md +++ /dev/null @@ -1,288 +0,0 @@ -### Single Server Example - -In this use case we have a single server with a static IP attached to it. It can be used in scenarios where one powerful VM has multiple benches and applications or one entry level VM with single site. For single bench, single site setup follow only up to the point where first bench and first site is added. If you choose this setup you can only scale vertically. If you need to scale horizontally you'll need to backup the sites and restore them on to cluster setup. - -We will setup the following: - -- Install docker and docker compose v2 on linux server. -- Install traefik service for internal load balancer and letsencrypt. -- Install MariaDB with containers. -- Setup project called `erpnext-one` and create sites `one.example.com` and `two.example.com` in the project. -- Setup project called `erpnext-two` and create sites `three.example.com` and `four.example.com` in the project. - -Explanation: - -Single instance of **Traefik** will be installed and act as internal loadbalancer for multiple benches and sites hosted on the server. It can also load balance other applications along with frappe benches, e.g. wordpress, metabase, etc. We only expose the ports `80` and `443` once with this instance of traefik. Traefik will also take care of letsencrypt automation for all sites installed on the server. _Why choose Traefik over Nginx Proxy Manager?_ Traefik doesn't need additional DB service and can store certificates in a json file in a volume. - -Single instance of **MariaDB** will be installed and act as database service for all the benches/projects installed on the server. - -Each instance of ERPNext project (bench) will have its own redis, socketio, gunicorn, nginx, workers and scheduler. It will connect to internal MariaDB by connecting to MariaDB network. It will expose sites to public through Traefik by connecting to Traefik network. - -### Install Docker - -Easiest way to install docker is to use the [convenience script](https://docs.docker.com/engine/install/ubuntu/#install-using-the-convenience-script). - -```shell -curl -fsSL https://get.docker.com | bash -``` - -Note: The documentation assumes Ubuntu LTS server is used. Use any distribution as long as the docker convenience script works. If the convenience script doesn't work, you'll need to install docker manually. - -### Install Compose V2 - -Refer [original documentation](https://docs.docker.com/compose/cli-command/#install-on-linux) for updated version. - -```shell -DOCKER_CONFIG=${DOCKER_CONFIG:-$HOME/.docker} -mkdir -p $DOCKER_CONFIG/cli-plugins -curl -SL https://github.com/docker/compose/releases/download/v2.2.3/docker-compose-linux-x86_64 -o $DOCKER_CONFIG/cli-plugins/docker-compose -chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose -``` - -### Prepare - -Clone `frappe_docker` repo for the needed YAMLs and change the current working directory of your shell to the cloned repo. - -```shell -git clone https://github.com/frappe/frappe_docker -cd frappe_docker -``` - -Create configuration and resources directory - -```shell -mkdir ~/gitops -``` - -The `~/gitops` directory will store all the resources that we use for setup. We will also keep the environment files in this directory as there will be multiple projects with different environment variables. You can create a private repo for this directory and track the changes there. - -### Install Traefik - -Basic Traefik setup using docker compose. - -Create a file called `traefik.env` in `~/gitops` - -```shell -echo 'TRAEFIK_DOMAIN=traefik.example.com' > ~/gitops/traefik.env -echo 'EMAIL=admin@example.com' >> ~/gitops/traefik.env -echo 'HASHED_PASSWORD='$(openssl passwd -apr1 changeit | sed 's/\$/\\\$/g') >> ~/gitops/traefik.env -``` - -Note: - -- Change the domain from `traefik.example.com` to the one used in production. DNS entry needs to point to the Server IP. -- Change the letsencrypt notification email from `admin@example.com` to correct email. -- Change the password from `changeit` to more secure. - -env file generated at location `~/gitops/traefik.env` will look like following: - -```env -TRAEFIK_DOMAIN=traefik.example.com -EMAIL=admin@example.com -HASHED_PASSWORD=$apr1$K.4gp7RT$tj9R2jHh0D4Gb5o5fIAzm/ -``` - -If Container does not deploy put the HASHED_PASSWORD in ''. - -Deploy the traefik container with letsencrypt SSL - -```shell -docker compose --project-name traefik \ - --env-file ~/gitops/traefik.env \ - -f overrides/compose.traefik.yaml \ - -f overrides/compose.traefik-ssl.yaml up -d -``` - -This will make the traefik dashboard available on `traefik.example.com` and all certificates will reside in the Docker volume `cert-data`. - -For LAN setup deploy the traefik container without overriding `overrides/compose.traefik-ssl.yaml`. - -### Install MariaDB - -Basic MariaDB setup using docker compose. - -Create a file called `mariadb.env` in `~/gitops` - -```shell -echo "DB_PASSWORD=changeit" > ~/gitops/mariadb.env -``` - -Note: - -- Change the password from `changeit` to more secure. - -env file generated at location `~/gitops/mariadb.env` will look like following: - -```env -DB_PASSWORD=changeit -``` - -Note: Change the password from `changeit` to more secure one. - -Deploy the mariadb container - -```shell -docker compose --project-name mariadb --env-file ~/gitops/mariadb.env -f overrides/compose.mariadb-shared.yaml up -d -``` - -This will make `mariadb-database` service available under `mariadb-network`. Data will reside in `/data/mariadb`. - -### Install ERPNext - -#### Create first bench - -Create first bench called `erpnext-one` with `one.example.com` and `two.example.com` - -Create a file called `erpnext-one.env` in `~/gitops` - -```shell -cp example.env ~/gitops/erpnext-one.env -sed -i 's/DB_PASSWORD=123/DB_PASSWORD=changeit/g' ~/gitops/erpnext-one.env -sed -i 's/DB_HOST=/DB_HOST=mariadb-database/g' ~/gitops/erpnext-one.env -sed -i 's/DB_PORT=/DB_PORT=3306/g' ~/gitops/erpnext-one.env -sed -i 's/SITES=`erp.example.com`/SITES=\`one.example.com\`,\`two.example.com\`/g' ~/gitops/erpnext-one.env -echo 'ROUTER=erpnext-one' >> ~/gitops/erpnext-one.env -echo "BENCH_NETWORK=erpnext-one" >> ~/gitops/erpnext-one.env -``` - -Note: - -- Change the password from `changeit` to the one set for MariaDB compose in the previous step. - -env file is generated at location `~/gitops/erpnext-one.env`. - -Create a yaml file called `erpnext-one.yaml` in `~/gitops` directory: - -```shell -docker compose --project-name erpnext-one \ - --env-file ~/gitops/erpnext-one.env \ - -f compose.yaml \ - -f overrides/compose.redis.yaml \ - -f overrides/compose.multi-bench.yaml \ - -f overrides/compose.multi-bench-ssl.yaml config > ~/gitops/erpnext-one.yaml -``` - -For LAN setup do not override `compose.multi-bench-ssl.yaml`. - -Use the above command after any changes are made to `erpnext-one.env` file to regenerate `~/gitops/erpnext-one.yaml`. e.g. after changing version to migrate the bench. - -Deploy `erpnext-one` containers: - -```shell -docker compose --project-name erpnext-one -f ~/gitops/erpnext-one.yaml up -d -``` - -Create sites `one.example.com` and `two.example.com`: - -```shell -# one.example.com -docker compose --project-name erpnext-one exec backend \ - bench new-site --no-mariadb-socket --mariadb-root-password changeit --install-app erpnext --admin-password changeit one.example.com -``` - -You can stop here and have a single bench single site setup complete. Continue to add one more site to the current bench. - -```shell -# two.example.com -docker compose --project-name erpnext-one exec backend \ - bench new-site --no-mariadb-socket --mariadb-root-password changeit --install-app erpnext --admin-password changeit two.example.com -``` - -#### Create second bench - -Setting up additional bench is optional. Continue only if you need multi bench setup. - -Create second bench called `erpnext-two` with `three.example.com` and `four.example.com` - -Create a file called `erpnext-two.env` in `~/gitops` - -```shell -curl -sL https://raw.githubusercontent.com/frappe/frappe_docker/main/example.env -o ~/gitops/erpnext-two.env -sed -i 's/DB_PASSWORD=123/DB_PASSWORD=changeit/g' ~/gitops/erpnext-two.env -sed -i 's/DB_HOST=/DB_HOST=mariadb-database/g' ~/gitops/erpnext-two.env -sed -i 's/DB_PORT=/DB_PORT=3306/g' ~/gitops/erpnext-two.env -echo "ROUTER=erpnext-two" >> ~/gitops/erpnext-two.env -echo "SITES=\`three.example.com\`,\`four.example.com\`" >> ~/gitops/erpnext-two.env -echo "BENCH_NETWORK=erpnext-two" >> ~/gitops/erpnext-two.env -``` - -Note: - -- Change the password from `changeit` to the one set for MariaDB compose in the previous step. - -env file is generated at location `~/gitops/erpnext-two.env`. - -Create a yaml file called `erpnext-two.yaml` in `~/gitops` directory: - -```shell -docker compose --project-name erpnext-two \ - --env-file ~/gitops/erpnext-two.env \ - -f compose.yaml \ - -f overrides/compose.redis.yaml \ - -f overrides/compose.multi-bench.yaml \ - -f overrides/compose.multi-bench-ssl.yaml config > ~/gitops/erpnext-two.yaml -``` - -Use the above command after any changes are made to `erpnext-two.env` file to regenerate `~/gitops/erpnext-two.yaml`. e.g. after changing version to migrate the bench. - -Deploy `erpnext-two` containers: - -```shell -docker compose --project-name erpnext-two -f ~/gitops/erpnext-two.yaml up -d -``` - -Create sites `three.example.com` and `four.example.com`: - -```shell -# three.example.com -docker compose --project-name erpnext-two exec backend \ - bench new-site --no-mariadb-socket --mariadb-root-password changeit --install-app erpnext --admin-password changeit three.example.com -# four.example.com -docker compose --project-name erpnext-two exec backend \ - bench new-site --no-mariadb-socket --mariadb-root-password changeit --install-app erpnext --admin-password changeit four.example.com -``` - -#### Create custom domain to existing site - -In case you need to point custom domain to existing site follow these steps. -Also useful if custom domain is required for LAN based access. - -Create environment file - -```shell -echo "ROUTER=custom-one-example" > ~/gitops/custom-one-example.env -echo "SITES=\`custom-one.example.com\`" >> ~/gitops/custom-one-example.env -echo "BASE_SITE=one.example.com" >> ~/gitops/custom-one-example.env -echo "BENCH_NETWORK=erpnext-one" >> ~/gitops/custom-one-example.env -``` - -Note: - -- Change the file name from `custom-one-example.env` to a logical one. -- Change `ROUTER` variable from `custom-one.example.com` to the one being added. -- Change `SITES` variable from `custom-one.example.com` to the one being added. You can add multiple sites quoted in backtick (`) and separated by commas. -- Change `BASE_SITE` variable from `one.example.com` to the one which is being pointed to. -- Change `BENCH_NETWORK` variable from `erpnext-one` to the one which was created with the bench. - -env file is generated at location mentioned in command. - -Generate yaml to reverse proxy: - -```shell -docker compose --project-name custom-one-example \ - --env-file ~/gitops/custom-one-example.env \ - -f overrides/compose.custom-domain.yaml \ - -f overrides/compose.custom-domain-ssl.yaml config > ~/gitops/custom-one-example.yaml -``` - -For LAN setup do not override `compose.custom-domain-ssl.yaml`. - -Deploy `erpnext-two` containers: - -```shell -docker compose --project-name custom-one-example -f ~/gitops/custom-one-example.yaml up -d -``` - -### Site operations - -Refer: [site operations](./site-operations.md) diff --git a/docs/site-operations.md b/docs/site-operations.md deleted file mode 100644 index 793dfef5..00000000 --- a/docs/site-operations.md +++ /dev/null @@ -1,85 +0,0 @@ -# Site operations - -> 💡 You should setup `--project-name` option in `docker-compose` commands if you have non-standard project name. - -## Setup new site - -Note: - -- Wait for the `db` service to start and `configurator` to exit before trying to create a new site. Usually this takes up to 10 seconds. - -```sh -docker-compose exec backend bench new-site --no-mariadb-socket --mariadb-root-password --admin-password -``` - -If you need to install some app, specify `--install-app`. To see all options, just run `bench new-site --help`. - -To create Postgres site (assuming you already use [Postgres compose override](images-and-compose-files.md#overrides)) you need have to do set `root_login` and `root_password` in common config before that: - -```sh -docker-compose exec backend bench set-config -g root_login -docker-compose exec backend bench set-config -g root_password -``` - -Also command is slightly different: - -```sh -docker-compose exec backend bench new-site --no-mariadb-socket --db-type postgres --admin-password -``` - -## Push backup to S3 storage - -We have the script that helps to push latest backup to S3. - -```sh -docker-compose exec backend push_backup.py --site-name --bucket --region-name --endpoint-url --aws-access-key-id --aws-secret-access-key -``` - -Note that you can restore backup only manually. - -## Edit configs - -Editing config manually might be required in some cases, -one such case is to use Amazon RDS (or any other DBaaS). -For full instructions, refer to the [wiki](). Common question can be found in Issues and on forum. - -`common_site_config.json` or `site_config.json` from `sites` volume has to be edited using following command: - -```sh -docker run --rm -it \ - -v _sites:/sites \ - alpine vi /sites/common_site_config.json -``` - -Instead of `alpine` use any image of your choice. - -## Health check - -For socketio and gunicorn service ping the hostname:port and that will be sufficient. For workers and scheduler, there is a command that needs to be executed. - -```shell -docker-compose exec backend healthcheck.sh --ping-service mongodb:27017 -``` - -Additional services can be pinged as part of health check with option `-p` or `--ping-service`. - -This check ensures that given service should be connected along with services in common_site_config.json. -If connection to service(s) fails, the command fails with exit code 1. - ---- - -For reference of commands like `backup`, `drop-site` or `migrate` check [official guide](https://frappeframework.com/docs/v13/user/en/bench/frappe-commands) or run: - -```sh -docker-compose exec backend bench --help -``` - -## Migrate site - -Note: - -- Wait for the `db` service to start and `configurator` to exit before trying to migrate a site. Usually this takes up to 10 seconds. - -```sh -docker-compose exec backend bench --site migrate -``` diff --git a/docs/troubleshoot.md b/docs/troubleshoot.md deleted file mode 100644 index 68718280..00000000 --- a/docs/troubleshoot.md +++ /dev/null @@ -1,55 +0,0 @@ -1. [Fixing MariaDB issues after rebuilding the container](#fixing-mariadb-issues-after-rebuilding-the-container) -1. [docker-compose does not recognize variables from `.env` file](#docker-compose-does-not-recognize-variables-from-env-file) -1. [Windows Based Installation](#windows-based-installation) - -### Fixing MariaDB issues after rebuilding the container - -For any reason after rebuilding the container if you are not be able to access MariaDB correctly with the previous configuration. Follow these instructions. - -The parameter `'db_name'@'%'` needs to be set in MariaDB and permission to the site database suitably assigned to the user. - -This step has to be repeated for all sites available under the current bench. -Example shows the queries to be executed for site `localhost` - -Open sites/localhost/site_config.json: - -```shell -code sites/localhost/site_config.json -``` - -and take note of the parameters `db_name` and `db_password`. - -Enter MariaDB Interactive shell: - -```shell -mysql -uroot -p123 -hmariadb -``` - -Execute following queries replacing `db_name` and `db_password` with the values found in site_config.json. - -```sql -UPDATE mysql.user SET Host = '%' where User = 'db_name'; FLUSH PRIVILEGES; -SET PASSWORD FOR 'db_name'@'%' = PASSWORD('db_password'); FLUSH PRIVILEGES; -GRANT ALL PRIVILEGES ON `db_name`.* TO 'db_name'@'%'; FLUSH PRIVILEGES; -EXIT; -``` - -Note: For MariaDB 10.4 and above use `mysql.global_priv` instead of `mysql.user`. - -### docker-compose does not recognize variables from `.env` file - -If you are using old version of `docker-compose` the .env file needs to be located in directory from where the docker-compose command is executed. There may also be difference in official `docker-compose` and the one packaged by distro. Use `--env-file=.env` if available to explicitly specify the path to file. - -### Windows Based Installation - -- Set environment variable `COMPOSE_CONVERT_WINDOWS_PATHS` e.g. `set COMPOSE_CONVERT_WINDOWS_PATHS=1` -- While using docker machine, port-forward the ports of VM to ports of host machine. (ports 8080/8000/9000) -- Name all the sites ending with `.localhost`. and access it via browser locally. e.g. `http://site1.localhost` - -### Redo installation - -- If you have made changes and just want to start over again (abandoning all changes), remove all docker - - containers - - images - - volumes -- Install a fresh diff --git a/images/bench/Dockerfile b/images/bench/Dockerfile index 291e4256..d83e437d 100644 --- a/images/bench/Dockerfile +++ b/images/bench/Dockerfile @@ -1,7 +1,5 @@ FROM debian:bookworm-slim as bench -LABEL author=frappé - ARG GIT_REPO=https://github.com/frappe/bench.git ARG GIT_BRANCH=v5.x diff --git a/images/custom/Containerfile b/images/custom/Containerfile index a7c0fd38..f4f08cfe 100644 --- a/images/custom/Containerfile +++ b/images/custom/Containerfile @@ -2,16 +2,13 @@ ARG PYTHON_VERSION=3.11.6 ARG DEBIAN_BASE=bookworm FROM python:${PYTHON_VERSION}-slim-${DEBIAN_BASE} AS base -COPY resources/nginx-template.conf /templates/nginx/frappe.conf.template -COPY resources/nginx-entrypoint.sh /usr/local/bin/nginx-entrypoint.sh - ARG WKHTMLTOPDF_VERSION=0.12.6.1-3 ARG WKHTMLTOPDF_DISTRO=bookworm ARG NODE_VERSION=18.18.2 -ENV NVM_DIR=/home/frappe/.nvm +ENV NVM_DIR=/home/zapal/.nvm ENV PATH ${NVM_DIR}/versions/node/v${NODE_VERSION}/bin/:${PATH} -RUN useradd -ms /bin/bash frappe \ +RUN useradd -ms /bin/bash zapal \ && apt-get update \ && apt-get install --no-install-recommends -y \ curl \ @@ -31,8 +28,8 @@ RUN useradd -ms /bin/bash frappe \ mariadb-client \ less \ # Postgres - libpq-dev \ - postgresql-client \ + # libpq-dev \ + # postgresql-client \ # For healthcheck wait-for-it \ jq \ @@ -45,9 +42,9 @@ RUN useradd -ms /bin/bash frappe \ && npm install -g yarn \ && nvm alias default v${NODE_VERSION} \ && rm -rf ${NVM_DIR}/.cache \ - && echo 'export NVM_DIR="/home/frappe/.nvm"' >>/home/frappe/.bashrc \ - && echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm' >>/home/frappe/.bashrc \ - && echo '[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion' >>/home/frappe/.bashrc \ + && echo 'export NVM_DIR="/home/zapal/.nvm"' >>/home/zapal/.bashrc \ + && echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm' >>/home/zapal/.bashrc \ + && echo '[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion' >>/home/zapal/.bashrc \ # Install wkhtmltopdf with patched qt && if [ "$(uname -m)" = "aarch64" ]; then export ARCH=arm64; fi \ && if [ "$(uname -m)" = "x86_64" ]; then export ARCH=amd64; fi \ @@ -63,13 +60,16 @@ RUN useradd -ms /bin/bash frappe \ && sed -i '/user www-data/d' /etc/nginx/nginx.conf \ && ln -sf /dev/stdout /var/log/nginx/access.log && ln -sf /dev/stderr /var/log/nginx/error.log \ && touch /run/nginx.pid \ - && chown -R frappe:frappe /etc/nginx/conf.d \ - && chown -R frappe:frappe /etc/nginx/nginx.conf \ - && chown -R frappe:frappe /var/log/nginx \ - && chown -R frappe:frappe /var/lib/nginx \ - && chown -R frappe:frappe /run/nginx.pid \ + && chown -R zapal:zapal /etc/nginx/conf.d \ + && chown -R zapal:zapal /etc/nginx/nginx.conf \ + && chown -R zapal:zapal /var/log/nginx \ + && chown -R zapal:zapal /var/lib/nginx \ + && chown -R zapal:zapal /run/nginx.pid \ && chmod 755 /usr/local/bin/nginx-entrypoint.sh \ - && chmod 644 /templates/nginx/frappe.conf.template + && chmod 644 /templates/nginx/erp.conf.template + +COPY resources/nginx-template.conf /templates/nginx/erp.conf.template +COPY resources/nginx-entrypoint.sh /usr/local/bin/nginx-entrypoint.sh FROM base AS builder @@ -103,43 +103,49 @@ RUN if [ -n "${APPS_JSON_BASE64}" ]; then \ mkdir /opt/frappe && echo "${APPS_JSON_BASE64}" | base64 -d > /opt/frappe/apps.json; \ fi -USER frappe +USER zapal ARG FRAPPE_BRANCH=version-15 -ARG FRAPPE_PATH=https://github.com/frappe/frappe -RUN export APP_INSTALL_ARGS="" && \ - if [ -n "${APPS_JSON_BASE64}" ]; then \ - export APP_INSTALL_ARGS="--apps_path=/opt/frappe/apps.json"; \ - fi && \ - bench init ${APP_INSTALL_ARGS}\ - --frappe-branch=${FRAPPE_BRANCH} \ - --frappe-path=${FRAPPE_PATH} \ - --no-procfile \ - --no-backups \ - --skip-redis-config-generation \ - --verbose \ - /home/frappe/frappe-bench && \ - cd /home/frappe/frappe-bench && \ +ARG FRAPPE_PATH=https://github.com/zapal-tech/erp-frappe +ARG ERPNEXT_REPO=https://github.com/zapal-tech/erp-erpnext +ARG ERPNEXT_BRANCH=version-15 +ARG HRMS_REPO=https://github.com/zapal-tech/erp-hrms +ARG HRMS_BRANCH=version-15 +ARG INSIGHTS_REPO=https://github.com/zapal-tech/erp-insights +ARG INSIGHTS_BRANCH=develop + +RUN bench init \ + --frappe-branch=${FRAPPE_BRANCH} \ + --frappe-path=${FRAPPE_PATH} \ + --no-procfile \ + --no-backups \ + --skip-redis-config-generation \ + --verbose \ + /home/zapal/frappe-bench && \ + cd /home/zapal/frappe-bench && \ + bench get-app --branch=${ERPNEXT_BRANCH} --resolve-deps erpnext ${ERPNEXT_REPO} && \ + bench get-app --branch=${HRMS_BRANCH} --resolve-deps hrms ${HRMS_REPO} && \ + bench get-app --branch=${INSIGHTS_BRANCH} --resolve-deps insights ${INSIGHTS_REPO} && \ echo "{}" > sites/common_site_config.json && \ find apps -mindepth 1 -path "*/.git" | xargs rm -fr -FROM base as backend +FROM base as erp -USER frappe +USER zapal -COPY --from=builder --chown=frappe:frappe /home/frappe/frappe-bench /home/frappe/frappe-bench +COPY --from=builder --chown=zapal:zapal /home/zapal/frappe-bench /home/zapal/frappe-bench -WORKDIR /home/frappe/frappe-bench +WORKDIR /home/zapal/frappe-bench VOLUME [ \ - "/home/frappe/frappe-bench/sites", \ - "/home/frappe/frappe-bench/sites/assets", \ - "/home/frappe/frappe-bench/logs" \ + "/home/zapal/frappe-bench/sites", \ + "/home/zapal/frappe-bench/sites/assets", \ + "/home/zapal/frappe-bench/logs" \ ] CMD [ \ - "/home/frappe/frappe-bench/env/bin/gunicorn", \ - "--chdir=/home/frappe/frappe-bench/sites", \ + "/home/zapal/frappe-bench/env/bin/gunicorn", \ + "--chdir=/home/zapal/frappe-bench/sites", \ "--bind=0.0.0.0:8000", \ "--threads=4", \ "--workers=2", \ diff --git a/images/production/Containerfile b/images/production/Containerfile index 65e412f8..81ec6bad 100644 --- a/images/production/Containerfile +++ b/images/production/Containerfile @@ -5,10 +5,10 @@ FROM python:${PYTHON_VERSION}-slim-${DEBIAN_BASE} AS base ARG WKHTMLTOPDF_VERSION=0.12.6.1-3 ARG WKHTMLTOPDF_DISTRO=bookworm ARG NODE_VERSION=18.18.2 -ENV NVM_DIR=/home/frappe/.nvm +ENV NVM_DIR=/home/zapal/.nvm ENV PATH ${NVM_DIR}/versions/node/v${NODE_VERSION}/bin/:${PATH} -RUN useradd -ms /bin/bash frappe \ +RUN useradd -ms /bin/bash zapal \ && apt-get update \ && apt-get install --no-install-recommends -y \ curl \ @@ -28,8 +28,8 @@ RUN useradd -ms /bin/bash frappe \ mariadb-client \ less \ # Postgres - libpq-dev \ - postgresql-client \ + # libpq-dev \ + # postgresql-client \ # For healthcheck wait-for-it \ jq \ @@ -42,9 +42,9 @@ RUN useradd -ms /bin/bash frappe \ && npm install -g yarn \ && nvm alias default v${NODE_VERSION} \ && rm -rf ${NVM_DIR}/.cache \ - && echo 'export NVM_DIR="/home/frappe/.nvm"' >>/home/frappe/.bashrc \ - && echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm' >>/home/frappe/.bashrc \ - && echo '[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion' >>/home/frappe/.bashrc \ + && echo 'export NVM_DIR="/home/zapal/.nvm"' >>/home/zapal/.bashrc \ + && echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm' >>/home/zapal/.bashrc \ + && echo '[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion' >>/home/zapal/.bashrc \ # Install wkhtmltopdf with patched qt && if [ "$(uname -m)" = "aarch64" ]; then export ARCH=arm64; fi \ && if [ "$(uname -m)" = "x86_64" ]; then export ARCH=amd64; fi \ @@ -60,13 +60,13 @@ RUN useradd -ms /bin/bash frappe \ && sed -i '/user www-data/d' /etc/nginx/nginx.conf \ && ln -sf /dev/stdout /var/log/nginx/access.log && ln -sf /dev/stderr /var/log/nginx/error.log \ && touch /run/nginx.pid \ - && chown -R frappe:frappe /etc/nginx/conf.d \ - && chown -R frappe:frappe /etc/nginx/nginx.conf \ - && chown -R frappe:frappe /var/log/nginx \ - && chown -R frappe:frappe /var/lib/nginx \ - && chown -R frappe:frappe /run/nginx.pid + && chown -R zapal:zapal /etc/nginx/conf.d \ + && chown -R zapal:zapal /etc/nginx/nginx.conf \ + && chown -R zapal:zapal /var/log/nginx \ + && chown -R zapal:zapal /var/lib/nginx \ + && chown -R zapal:zapal /run/nginx.pid -COPY resources/nginx-template.conf /templates/nginx/frappe.conf.template +COPY resources/nginx-template.conf /templates/nginx/erp.conf.template COPY resources/nginx-entrypoint.sh /usr/local/bin/nginx-entrypoint.sh FROM base AS builder @@ -95,12 +95,17 @@ RUN apt-get update \ libbz2-dev \ && rm -rf /var/lib/apt/lists/* -USER frappe +USER zapal ARG FRAPPE_BRANCH=version-15 -ARG FRAPPE_PATH=https://github.com/frappe/frappe -ARG ERPNEXT_REPO=https://github.com/frappe/erpnext +ARG FRAPPE_PATH=https://github.com/zapal-tech/erp-frappe +ARG ERPNEXT_REPO=https://github.com/zapal-tech/erp-erpnext ARG ERPNEXT_BRANCH=version-15 +ARG HRMS_REPO=https://github.com/zapal-tech/erp-hrms +ARG HRMS_BRANCH=version-15 +ARG INSIGHTS_REPO=https://github.com/zapal-tech/erp-insights +ARG INSIGHTS_BRANCH=develop + RUN bench init \ --frappe-branch=${FRAPPE_BRANCH} \ --frappe-path=${FRAPPE_PATH} \ @@ -108,29 +113,31 @@ RUN bench init \ --no-backups \ --skip-redis-config-generation \ --verbose \ - /home/frappe/frappe-bench && \ - cd /home/frappe/frappe-bench && \ + /home/zapal/frappe-bench && \ + cd /home/zapal/frappe-bench && \ bench get-app --branch=${ERPNEXT_BRANCH} --resolve-deps erpnext ${ERPNEXT_REPO} && \ + bench get-app --branch=${HRMS_BRANCH} --resolve-deps hrms ${HRMS_REPO} && \ + bench get-app --branch=${INSIGHTS_BRANCH} --resolve-deps insights ${INSIGHTS_REPO} && \ echo "{}" > sites/common_site_config.json && \ find apps -mindepth 1 -path "*/.git" | xargs rm -fr -FROM base as erpnext +FROM base as erp -USER frappe +USER zapal -COPY --from=builder --chown=frappe:frappe /home/frappe/frappe-bench /home/frappe/frappe-bench +COPY --from=builder --chown=zapal:zapal /home/zapal/frappe-bench /home/zapal/frappe-bench -WORKDIR /home/frappe/frappe-bench +WORKDIR /home/zapal/frappe-bench VOLUME [ \ - "/home/frappe/frappe-bench/sites", \ - "/home/frappe/frappe-bench/sites/assets", \ - "/home/frappe/frappe-bench/logs" \ + "/home/zapal/frappe-bench/sites", \ + "/home/zapal/frappe-bench/sites/assets", \ + "/home/zapal/frappe-bench/logs" \ ] CMD [ \ - "/home/frappe/frappe-bench/env/bin/gunicorn", \ - "--chdir=/home/frappe/frappe-bench/sites", \ + "/home/zapal/frappe-bench/env/bin/gunicorn", \ + "--chdir=/home/zapal/frappe-bench/sites", \ "--bind=0.0.0.0:8000", \ "--threads=4", \ "--workers=2", \ diff --git a/install_x11_deps.sh b/install_x11_deps.sh old mode 100755 new mode 100644 diff --git a/overrides/compose.multi-bench-ssl.yaml b/overrides/compose.multi-bench-ssl.yaml deleted file mode 100644 index 158d22bd..00000000 --- a/overrides/compose.multi-bench-ssl.yaml +++ /dev/null @@ -1,14 +0,0 @@ -services: - frontend: - labels: - # ${ROUTER}-http to use the middleware to redirect to https - - traefik.http.routers.${ROUTER}-http.middlewares=https-redirect - # ${ROUTER}-https the actual router using HTTPS - # Uses the environment variable SITES - - traefik.http.routers.${ROUTER}-https.rule=Host(${SITES?SITES not set}) - - traefik.http.routers.${ROUTER}-https.entrypoints=https - - traefik.http.routers.${ROUTER}-https.tls=true - # Use the service ${ROUTER} with the frontend - - traefik.http.routers.${ROUTER}-https.service=${ROUTER} - # Use the "le" (Let's Encrypt) resolver created below - - traefik.http.routers.${ROUTER}-https.tls.certresolver=le diff --git a/overrides/compose.multi-bench.yaml b/overrides/compose.multi-bench.yaml deleted file mode 100644 index 7e681a18..00000000 --- a/overrides/compose.multi-bench.yaml +++ /dev/null @@ -1,54 +0,0 @@ -services: - frontend: - networks: - - traefik-public - - bench-network - labels: - - traefik.enable=true - - traefik.docker.network=traefik-public - - traefik.http.services.${ROUTER?ROUTER not set}.loadbalancer.server.port=8080 - - traefik.http.routers.${ROUTER}-http.service=${ROUTER} - - traefik.http.routers.${ROUTER}-http.entrypoints=http - - traefik.http.routers.${ROUTER}-http.rule=Host(${SITES?SITES not set}) - configurator: - networks: - - bench-network - - mariadb-network - backend: - networks: - - mariadb-network - - bench-network - websocket: - networks: - - bench-network - - mariadb-network - scheduler: - networks: - - bench-network - - mariadb-network - queue-short: - networks: - - bench-network - - mariadb-network - queue-long: - networks: - - bench-network - - mariadb-network - redis-cache: - networks: - - bench-network - - mariadb-network - - redis-queue: - networks: - - bench-network - - mariadb-network - -networks: - traefik-public: - external: true - mariadb-network: - external: true - bench-network: - name: ${ROUTER} - external: false diff --git a/overrides/compose.postgres.yaml b/overrides/compose.postgres.yaml deleted file mode 100644 index 433ca71a..00000000 --- a/overrides/compose.postgres.yaml +++ /dev/null @@ -1,18 +0,0 @@ -services: - configurator: - environment: - DB_HOST: db - DB_PORT: 5432 - depends_on: - - db - - db: - image: postgres:13.5 - command: [] - environment: - POSTGRES_PASSWORD: ${DB_PASSWORD:?No db password set} - volumes: - - db-data:/var/lib/postgresql/data - -volumes: - db-data: diff --git a/overrides/compose.traefik-ssl.yaml b/overrides/compose.traefik-ssl.yaml deleted file mode 100644 index 0c0a9b84..00000000 --- a/overrides/compose.traefik-ssl.yaml +++ /dev/null @@ -1,48 +0,0 @@ -services: - traefik: - labels: - # https-redirect middleware to redirect HTTP to HTTPS - # It can be re-used by other stacks in other Docker Compose files - - traefik.http.middlewares.https-redirect.redirectscheme.scheme=https - - traefik.http.middlewares.https-redirect.redirectscheme.permanent=true - # traefik-http to use the middleware to redirect to https - - traefik.http.routers.traefik-public-http.middlewares=https-redirect - # traefik-https the actual router using HTTPS - # Uses the environment variable DOMAIN - - traefik.http.routers.traefik-public-https.rule=Host(`${TRAEFIK_DOMAIN}`) - - traefik.http.routers.traefik-public-https.entrypoints=https - - traefik.http.routers.traefik-public-https.tls=true - # Use the special Traefik service api@internal with the web UI/Dashboard - - traefik.http.routers.traefik-public-https.service=api@internal - # Use the "le" (Let's Encrypt) resolver created below - - traefik.http.routers.traefik-public-https.tls.certresolver=le - # Enable HTTP Basic auth, using the middleware created above - - traefik.http.routers.traefik-public-https.middlewares=admin-auth - command: - # Enable Docker in Traefik, so that it reads labels from Docker services - - --providers.docker=true - # Do not expose all Docker services, only the ones explicitly exposed - - --providers.docker.exposedbydefault=false - # Create an entrypoint http listening on port 80 - - --entrypoints.http.address=:80 - # Create an entrypoint https listening on port 443 - - --entrypoints.https.address=:443 - # Create the certificate resolver le for Let's Encrypt, uses the environment variable EMAIL - - --certificatesresolvers.le.acme.email=${EMAIL:?No EMAIL set} - # Store the Let's Encrypt certificates in the mounted volume - - --certificatesresolvers.le.acme.storage=/certificates/acme.json - # Use the TLS Challenge for Let's Encrypt - - --certificatesresolvers.le.acme.tlschallenge=true - # Enable the access log, with HTTP requests - - --accesslog - # Enable the Traefik log, for configurations and errors - - --log - # Enable the Dashboard and API - - --api - ports: - - 443:443 - volumes: - - cert-data:/certificates - -volumes: - cert-data: diff --git a/pwd.yml b/pwd.yml deleted file mode 100644 index a2c1c858..00000000 --- a/pwd.yml +++ /dev/null @@ -1,188 +0,0 @@ -version: "3" - -services: - backend: - image: frappe/erpnext:v15.13.0 - deploy: - restart_policy: - condition: on-failure - volumes: - - sites:/home/frappe/frappe-bench/sites - - logs:/home/frappe/frappe-bench/logs - - configurator: - image: frappe/erpnext:v15.13.0 - deploy: - restart_policy: - condition: none - entrypoint: - - bash - - -c - # add redis_socketio for backward compatibility - command: - - > - ls -1 apps > sites/apps.txt; - bench set-config -g db_host $$DB_HOST; - bench set-config -gp db_port $$DB_PORT; - bench set-config -g redis_cache "redis://$$REDIS_CACHE"; - bench set-config -g redis_queue "redis://$$REDIS_QUEUE"; - bench set-config -g redis_socketio "redis://$$REDIS_QUEUE"; - bench set-config -gp socketio_port $$SOCKETIO_PORT; - environment: - DB_HOST: db - DB_PORT: "3306" - REDIS_CACHE: redis-cache:6379 - REDIS_QUEUE: redis-queue:6379 - SOCKETIO_PORT: "9000" - volumes: - - sites:/home/frappe/frappe-bench/sites - - logs:/home/frappe/frappe-bench/logs - - create-site: - image: frappe/erpnext:v15.13.0 - deploy: - restart_policy: - condition: none - volumes: - - sites:/home/frappe/frappe-bench/sites - - logs:/home/frappe/frappe-bench/logs - entrypoint: - - bash - - -c - command: - - > - wait-for-it -t 120 db:3306; - wait-for-it -t 120 redis-cache:6379; - wait-for-it -t 120 redis-queue:6379; - export start=`date +%s`; - until [[ -n `grep -hs ^ sites/common_site_config.json | jq -r ".db_host // empty"` ]] && \ - [[ -n `grep -hs ^ sites/common_site_config.json | jq -r ".redis_cache // empty"` ]] && \ - [[ -n `grep -hs ^ sites/common_site_config.json | jq -r ".redis_queue // empty"` ]]; - do - echo "Waiting for sites/common_site_config.json to be created"; - sleep 5; - if (( `date +%s`-start > 120 )); then - echo "could not find sites/common_site_config.json with required keys"; - exit 1 - fi - done; - echo "sites/common_site_config.json found"; - bench new-site --no-mariadb-socket --admin-password=admin --db-root-password=admin --install-app erpnext --set-default frontend; - - db: - image: mariadb:10.6 - healthcheck: - test: mysqladmin ping -h localhost --password=admin - interval: 1s - retries: 15 - deploy: - restart_policy: - condition: on-failure - command: - - --character-set-server=utf8mb4 - - --collation-server=utf8mb4_unicode_ci - - --skip-character-set-client-handshake - - --skip-innodb-read-only-compressed # Temporary fix for MariaDB 10.6 - environment: - MYSQL_ROOT_PASSWORD: admin - volumes: - - db-data:/var/lib/mysql - - frontend: - image: frappe/erpnext:v15.13.0 - depends_on: - - websocket - deploy: - restart_policy: - condition: on-failure - command: - - nginx-entrypoint.sh - environment: - BACKEND: backend:8000 - FRAPPE_SITE_NAME_HEADER: frontend - SOCKETIO: websocket:9000 - UPSTREAM_REAL_IP_ADDRESS: 127.0.0.1 - UPSTREAM_REAL_IP_HEADER: X-Forwarded-For - UPSTREAM_REAL_IP_RECURSIVE: "off" - PROXY_READ_TIMEOUT: 120 - CLIENT_MAX_BODY_SIZE: 50m - volumes: - - sites:/home/frappe/frappe-bench/sites - - logs:/home/frappe/frappe-bench/logs - ports: - - "8080:8080" - - queue-long: - image: frappe/erpnext:v15.13.0 - deploy: - restart_policy: - condition: on-failure - command: - - bench - - worker - - --queue - - long,default,short - volumes: - - sites:/home/frappe/frappe-bench/sites - - logs:/home/frappe/frappe-bench/logs - - queue-short: - image: frappe/erpnext:v15.13.0 - deploy: - restart_policy: - condition: on-failure - command: - - bench - - worker - - --queue - - short,default - volumes: - - sites:/home/frappe/frappe-bench/sites - - logs:/home/frappe/frappe-bench/logs - - redis-queue: - image: redis:6.2-alpine - deploy: - restart_policy: - condition: on-failure - volumes: - - redis-queue-data:/data - - redis-cache: - image: redis:6.2-alpine - deploy: - restart_policy: - condition: on-failure - volumes: - - redis-cache-data:/data - - scheduler: - image: frappe/erpnext:v15.13.0 - deploy: - restart_policy: - condition: on-failure - command: - - bench - - schedule - volumes: - - sites:/home/frappe/frappe-bench/sites - - logs:/home/frappe/frappe-bench/logs - - websocket: - image: frappe/erpnext:v15.13.0 - deploy: - restart_policy: - condition: on-failure - command: - - node - - /home/frappe/frappe-bench/apps/frappe/socketio.js - volumes: - - sites:/home/frappe/frappe-bench/sites - - logs:/home/frappe/frappe-bench/logs - -volumes: - db-data: - redis-queue-data: - redis-cache-data: - sites: - logs: diff --git a/resources/nginx-entrypoint.sh b/resources/nginx-entrypoint.sh old mode 100755 new mode 100644 index 50408e56..df45d9ef --- a/resources/nginx-entrypoint.sh +++ b/resources/nginx-entrypoint.sh @@ -2,12 +2,12 @@ # Set variables that do not exist if [[ -z "$BACKEND" ]]; then - echo "BACKEND defaulting to 0.0.0.0:8000" - export BACKEND=0.0.0.0:8000 + echo "BACKEND defaulting to 0.0.0.0:8900" + export BACKEND=0.0.0.0:8900 fi if [[ -z "$SOCKETIO" ]]; then - echo "SOCKETIO defaulting to 0.0.0.0:9000" - export SOCKETIO=0.0.0.0:9000 + echo "SOCKETIO defaulting to 0.0.0.0:8910" + export SOCKETIO=0.0.0.0:8910 fi if [[ -z "$UPSTREAM_REAL_IP_ADDRESS" ]]; then echo "UPSTREAM_REAL_IP_ADDRESS defaulting to 127.0.0.1" @@ -47,6 +47,6 @@ envsubst '${BACKEND} ${FRAPPE_SITE_NAME_HEADER} ${PROXY_READ_TIMEOUT} ${CLIENT_MAX_BODY_SIZE}' \ - /etc/nginx/conf.d/frappe.conf + /etc/nginx/conf.d/erp.conf nginx -g 'daemon off;' diff --git a/resources/nginx-template.conf b/resources/nginx-template.conf index e6d796a3..63d57c04 100644 --- a/resources/nginx-template.conf +++ b/resources/nginx-template.conf @@ -9,7 +9,7 @@ upstream socketio-server { server { listen 8080; server_name ${FRAPPE_SITE_NAME_HEADER}; - root /home/frappe/frappe-bench/sites; + root /home/zapal/frappe-bench/sites; proxy_buffer_size 128k; proxy_buffers 4 256k; diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index ae4ce36a..00000000 --- a/setup.cfg +++ /dev/null @@ -1,12 +0,0 @@ -# Config file for isort, codespell and other Python projects. -# In this case it is not used for packaging. - -[isort] -profile = black -known_third_party = frappe - -[codespell] -skip = images/bench/Dockerfile - -[tool:pytest] -addopts = -s --exitfirst diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/_check_connections.py b/tests/_check_connections.py deleted file mode 100644 index 85ce87fc..00000000 --- a/tests/_check_connections.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import socket -from typing import Any, Iterable, Tuple - -Address = Tuple[str, int] - - -async def wait_for_port(address: Address) -> None: - # From https://github.com/clarketm/wait-for-it - while True: - try: - _, writer = await asyncio.open_connection(*address) - writer.close() - await writer.wait_closed() - break - except (socket.gaierror, ConnectionError, OSError, TypeError): - pass - await asyncio.sleep(0.1) - - -def get_redis_url(addr: str) -> Address: - result = addr.replace("redis://", "") - result = result.split("/")[0] - parts = result.split(":") - assert len(parts) == 2 - return parts[0], int(parts[1]) - - -def get_addresses(config: dict[str, Any]) -> Iterable[Address]: - yield (config["db_host"], config["db_port"]) - for key in ("redis_cache", "redis_queue"): - yield get_redis_url(config[key]) - - -async def async_main(addresses: set[Address]) -> None: - tasks = [asyncio.wait_for(wait_for_port(addr), timeout=5) for addr in addresses] - await asyncio.gather(*tasks) - - -def main() -> int: - with open("/home/frappe/frappe-bench/sites/common_site_config.json") as f: - config = json.load(f) - addresses = set(get_addresses(config)) - asyncio.run(async_main(addresses)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/_check_website_theme.py b/tests/_check_website_theme.py deleted file mode 100644 index bc7ca8f4..00000000 --- a/tests/_check_website_theme.py +++ /dev/null @@ -1,17 +0,0 @@ -import frappe - - -def check_website_theme(): - doc = frappe.new_doc("Website Theme") - doc.theme = "test theme" - doc.insert() - - -def main() -> int: - frappe.connect(site="tests") - check_website_theme() - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/_create_bucket.py b/tests/_create_bucket.py deleted file mode 100644 index e0b55cbf..00000000 --- a/tests/_create_bucket.py +++ /dev/null @@ -1,19 +0,0 @@ -import os - -import boto3 - - -def main() -> int: - resource = boto3.resource( - service_name="s3", - endpoint_url="http://minio:9000", - region_name="us-east-1", - aws_access_key_id=os.getenv("S3_ACCESS_KEY"), - aws_secret_access_key=os.getenv("S3_SECRET_KEY"), - ) - resource.create_bucket(Bucket="frappe") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/_ping_frappe_connections.py b/tests/_ping_frappe_connections.py deleted file mode 100644 index 80aae105..00000000 --- a/tests/_ping_frappe_connections.py +++ /dev/null @@ -1,26 +0,0 @@ -import frappe - - -def check_db(): - doc = frappe.get_single("System Settings") - assert any(v is None for v in doc.as_dict().values()), "Database test didn't pass" - print("Database works!") - - -def check_cache(): - key_and_name = "mytestkey", "mytestname" - frappe.cache().hset(*key_and_name, "mytestvalue") - assert frappe.cache().hget(*key_and_name) == "mytestvalue", "Cache test didn't pass" - frappe.cache().hdel(*key_and_name) - print("Cache works!") - - -def main() -> int: - frappe.connect(site="tests.localhost") - check_db() - check_cache() - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/compose.ci.yaml b/tests/compose.ci.yaml deleted file mode 100644 index 81c952a5..00000000 --- a/tests/compose.ci.yaml +++ /dev/null @@ -1,21 +0,0 @@ -services: - configurator: - image: localhost:5000/frappe/erpnext:${ERPNEXT_VERSION} - - backend: - image: localhost:5000/frappe/erpnext:${ERPNEXT_VERSION} - - frontend: - image: localhost:5000/frappe/erpnext:${ERPNEXT_VERSION} - - websocket: - image: localhost:5000/frappe/erpnext:${ERPNEXT_VERSION} - - queue-short: - image: localhost:5000/frappe/erpnext:${ERPNEXT_VERSION} - - queue-long: - image: localhost:5000/frappe/erpnext:${ERPNEXT_VERSION} - - scheduler: - image: localhost:5000/frappe/erpnext:${ERPNEXT_VERSION} diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 99470aa4..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,168 +0,0 @@ -import os -import re -import shutil -import subprocess -from dataclasses import dataclass -from pathlib import Path - -import pytest - -from tests.utils import CI, Compose - - -def _add_version_var(name: str, env_path: Path): - value = os.getenv(name) - - if not value: - return - - if value == "develop": - os.environ[name] = "latest" - - with open(env_path, "a") as f: - f.write(f"\n{name}={os.environ[name]}") - - -def _add_sites_var(env_path: Path): - with open(env_path, "r+") as f: - content = f.read() - content = re.sub( - rf"SITES=.*", - f"SITES=`tests.localhost`,`test-erpnext-site.localhost`,`test-pg-site.localhost`", - content, - ) - f.seek(0) - f.truncate() - f.write(content) - - -@pytest.fixture(scope="session") -def env_file(tmp_path_factory: pytest.TempPathFactory): - tmp_path = tmp_path_factory.mktemp("frappe-docker") - file_path = tmp_path / ".env" - shutil.copy("example.env", file_path) - - _add_sites_var(file_path) - - for var in ("FRAPPE_VERSION", "ERPNEXT_VERSION"): - _add_version_var(name=var, env_path=file_path) - - yield str(file_path) - os.remove(file_path) - - -@pytest.fixture(scope="session") -def compose(env_file: str): - return Compose(project_name="test", env_file=env_file) - - -@pytest.fixture(autouse=True, scope="session") -def frappe_setup(compose: Compose): - compose.stop() - - compose("up", "-d", "--quiet-pull") - yield - - compose.stop() - - -@pytest.fixture(scope="session") -def frappe_site(compose: Compose): - site_name = "tests.localhost" - compose.bench( - "new-site", - "--no-mariadb-socket", - "--mariadb-root-password", - "123", - "--admin-password", - "admin", - site_name, - ) - compose("restart", "backend") - yield site_name - - -@pytest.fixture(scope="class") -def erpnext_setup(compose: Compose): - compose.stop() - compose("up", "-d", "--quiet-pull") - - yield - compose.stop() - - -@pytest.fixture(scope="class") -def erpnext_site(compose: Compose): - site_name = "test-erpnext-site.localhost" - args = [ - "new-site", - "--no-mariadb-socket", - "--mariadb-root-password", - "123", - "--admin-password", - "admin", - "--install-app", - "erpnext", - site_name, - ] - compose.bench(*args) - compose("restart", "backend") - yield site_name - - -@pytest.fixture -def postgres_setup(compose: Compose): - compose.stop() - compose("-f", "overrides/compose.postgres.yaml", "up", "-d", "--quiet-pull") - compose.bench("set-config", "-g", "root_login", "postgres") - compose.bench("set-config", "-g", "root_password", "123") - yield - compose.stop() - - -@pytest.fixture -def python_path(): - return "/home/frappe/frappe-bench/env/bin/python" - - -@dataclass -class S3ServiceResult: - access_key: str - secret_key: str - - -@pytest.fixture -def s3_service(python_path: str, compose: Compose): - access_key = "AKIAIOSFODNN7EXAMPLE" - secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" - cmd = ( - "docker", - "run", - "--name", - "minio", - "-d", - "-e", - f"MINIO_ACCESS_KEY={access_key}", - "-e", - f"MINIO_SECRET_KEY={secret_key}", - "--network", - f"{compose.project_name}_default", - "minio/minio", - "server", - "/data", - ) - subprocess.check_call(cmd) - - compose("cp", "tests/_create_bucket.py", "backend:/tmp") - compose.exec( - "-e", - f"S3_ACCESS_KEY={access_key}", - "-e", - f"S3_SECRET_KEY={secret_key}", - "backend", - python_path, - "/tmp/_create_bucket.py", - ) - - yield S3ServiceResult(access_key=access_key, secret_key=secret_key) - subprocess.call(("docker", "rm", "minio", "-f")) diff --git a/tests/test_frappe_docker.py b/tests/test_frappe_docker.py deleted file mode 100644 index 85e6f1e8..00000000 --- a/tests/test_frappe_docker.py +++ /dev/null @@ -1,151 +0,0 @@ -import os -from pathlib import Path -from typing import Any - -import pytest - -from tests.conftest import S3ServiceResult -from tests.utils import Compose, check_url_content - -BACKEND_SERVICES = ( - "backend", - "queue-short", - "queue-long", - "scheduler", -) - - -@pytest.mark.parametrize("service", BACKEND_SERVICES) -def test_links_in_backends(service: str, compose: Compose, python_path: str): - filename = "_check_connections.py" - compose("cp", f"tests/{filename}", f"{service}:/tmp/") - compose.exec(service, python_path, f"/tmp/{filename}") - - -def index_cb(text: str): - if "404 page not found" not in text: - return text[:200] - - -def api_cb(text: str): - if '"message"' in text: - return text - - -def assets_cb(text: str): - if text: - return text[:200] - - -@pytest.mark.parametrize( - ("url", "callback"), (("/", index_cb), ("/api/method/ping", api_cb)) -) -def test_endpoints(url: str, callback: Any, frappe_site: str): - check_url_content( - url=f"http://127.0.0.1{url}", callback=callback, site_name=frappe_site - ) - - -@pytest.mark.skipif( - os.environ["FRAPPE_VERSION"][0:3] == "v12", reason="v12 doesn't have the asset" -) -def test_assets_endpoint(frappe_site: str): - check_url_content( - url=f"http://127.0.0.1/assets/frappe/images/frappe-framework-logo.svg", - callback=assets_cb, - site_name=frappe_site, - ) - - -def test_files_reachable(frappe_site: str, tmp_path: Path, compose: Compose): - content = "lalala\n" - file_path = tmp_path / "testfile.txt" - - with file_path.open("w") as f: - f.write(content) - - compose( - "cp", - str(file_path), - f"backend:/home/frappe/frappe-bench/sites/{frappe_site}/public/files/", - ) - - def callback(text: str): - if text == content: - return text - - check_url_content( - url=f"http://127.0.0.1/files/{file_path.name}", - callback=callback, - site_name=frappe_site, - ) - - -@pytest.mark.parametrize("service", BACKEND_SERVICES) -@pytest.mark.usefixtures("frappe_site") -def test_frappe_connections_in_backends( - service: str, python_path: str, compose: Compose -): - filename = "_ping_frappe_connections.py" - compose("cp", f"tests/{filename}", f"{service}:/tmp/") - compose.exec( - "-w", - "/home/frappe/frappe-bench/sites", - service, - python_path, - f"/tmp/{filename}", - ) - - -def test_push_backup( - frappe_site: str, - s3_service: S3ServiceResult, - compose: Compose, -): - restic_password = "secret" - compose.bench("--site", frappe_site, "backup", "--with-files") - restic_args = [ - "--env=RESTIC_REPOSITORY=s3:http://minio:9000/frappe", - f"--env=AWS_ACCESS_KEY_ID={s3_service.access_key}", - f"--env=AWS_SECRET_ACCESS_KEY={s3_service.secret_key}", - f"--env=RESTIC_PASSWORD={restic_password}", - ] - compose.exec(*restic_args, "backend", "restic", "init") - compose.exec(*restic_args, "backend", "restic", "backup", "sites") - compose.exec(*restic_args, "backend", "restic", "snapshots") - - -def test_https(frappe_site: str, compose: Compose): - compose("-f", "overrides/compose.https.yaml", "up", "-d") - check_url_content(url="https://127.0.0.1", callback=index_cb, site_name=frappe_site) - - -@pytest.mark.usefixtures("erpnext_setup") -class TestErpnext: - @pytest.mark.parametrize( - ("url", "callback"), - ( - ( - "/api/method/erpnext.templates.pages.search_help.get_help_results_sections?text=help", - api_cb, - ), - ("/assets/erpnext/js/setup_wizard.js", assets_cb), - ), - ) - def test_endpoints(self, url: str, callback: Any, erpnext_site: str): - check_url_content( - url=f"http://127.0.0.1{url}", callback=callback, site_name=erpnext_site - ) - - -@pytest.mark.usefixtures("postgres_setup") -class TestPostgres: - def test_site_creation(self, compose: Compose): - compose.bench( - "new-site", - "test-pg-site.localhost", - "--db-type", - "postgres", - "--admin-password", - "admin", - ) diff --git a/tests/utils.py b/tests/utils.py deleted file mode 100644 index fc12198b..00000000 --- a/tests/utils.py +++ /dev/null @@ -1,89 +0,0 @@ -import os -import ssl -import subprocess -import sys -import time -from contextlib import suppress -from typing import Callable, Optional -from urllib.error import HTTPError, URLError -from urllib.request import Request, urlopen - -CI = os.getenv("CI") - - -class Compose: - def __init__(self, project_name: str, env_file: str): - self.project_name = project_name - self.base_cmd = ( - "docker", - "compose", - "-p", - project_name, - "--env-file", - env_file, - ) - - def __call__(self, *cmd: str) -> None: - file_args = [ - "-f", - "compose.yaml", - "-f", - "overrides/compose.proxy.yaml", - "-f", - "overrides/compose.mariadb.yaml", - "-f", - "overrides/compose.redis.yaml", - ] - if CI: - file_args += ("-f", "tests/compose.ci.yaml") - - args = self.base_cmd + tuple(file_args) + cmd - subprocess.check_call(args) - - def exec(self, *cmd: str) -> None: - if sys.stdout.isatty(): - self("exec", *cmd) - else: - self("exec", "-T", *cmd) - - def stop(self) -> None: - # Stop all containers in `test` project if they are running. - # We don't care if it fails. - with suppress(subprocess.CalledProcessError): - subprocess.check_call(self.base_cmd + ("down", "-v", "--remove-orphans")) - - def bench(self, *cmd: str) -> None: - self.exec("backend", "bench", *cmd) - - -def check_url_content( - url: str, callback: Callable[[str], Optional[str]], site_name: str -): - request = Request(url, headers={"Host": site_name}) - - # This is needed to check https override - ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - - for _ in range(100): - try: - response = urlopen(request, context=ctx) - - except HTTPError as exc: - if exc.code not in (404, 502): - raise - - except URLError: - pass - - else: - text: str = response.read().decode() - ret = callback(text) - if ret: - print(ret) - return - - time.sleep(0.1) - - raise RuntimeError(f"Couldn't ping {url}")