├── tests ├── __init__.py ├── pytest.ini ├── test_doodba_qa.py ├── test_postgres.py ├── test_default_settings.py ├── test_migrations.py ├── test_settings_effect.py ├── test_routing.py ├── conftest.py └── test_tasks_downstream.py ├── odoo ├── custom │ ├── ssh │ │ ├── id_rsa │ │ ├── id_rsa.pub │ │ ├── config │ │ └── known_hosts │ ├── build.d │ │ ├── .empty │ │ ├── {% if odoo_version < 11 %}10-fix-certs{% endif %}.jinja │ │ └── {% if odoo_version < 11 %}20-update-pg-repos{% endif %}.jinja │ ├── conf.d │ │ └── .empty │ ├── src │ │ ├── addons.yaml │ │ ├── private │ │ │ └── .editorconfig.jinja │ │ └── repos.yaml │ ├── dependencies │ │ ├── apt.txt │ │ ├── gem.txt │ │ ├── npm.txt │ │ ├── apt_build.txt │ │ └── pip.txt.jinja │ └── entrypoint.d │ │ └── .empty ├── Dockerfile └── .dockerignore ├── .docker ├── db-access.env.jinja ├── odoo.env.jinja ├── db-creation.env.jinja ├── {% if smtp_relay_host %}smtp.env{% endif %}.jinja └── {% if backup_dst %}backup.env{% endif %}.jinja ├── docs ├── res │ └── vscode_py_js_debug.gif ├── scaffolding2copier.sh └── migrating-from-doodba-scaffolding.md ├── tasks.py.jinja ├── .eslintrc.yml.jinja ├── {{ _copier_conf.answers_file }}.jinja ├── .gitmodules ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md ├── dependabot.yml └── workflows │ ├── dependabot-merge.yml │ └── test.yml ├── .prettierrc.yml ├── .editorconfig ├── {% if odoo_version <= 12 %}.flake8{% endif %}.jinja ├── {% if odoo_version <= 12 %}.isort.cfg{% endif %}.jinja ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── doodba.code-snippets ├── .gitignore ├── .ruff.toml.jinja ├── pyproject.toml ├── COPYING ├── setup-devel.yaml.jinja ├── .pre-commit-config.yaml ├── README.md.jinja ├── .pylintrc-mandatory.jinja ├── _macros.jinja ├── tasks.py ├── .module-readme.rst.j2 ├── CONTRIBUTING.md ├── _traefik2_hosts_labels.yml.jinja ├── _traefik1_labels.yml.jinja ├── _traefik3_labels.yml.jinja ├── common.yaml.jinja ├── .pylintrc.jinja ├── devel.yaml.jinja ├── prod.yaml.jinja ├── test.yaml.jinja ├── README.md ├── .pre-commit-config.yaml.jinja ├── migrations.py ├── _traefik1_paths_labels.yml.jinja ├── _traefik2_labels.yml.jinja └── _traefik3_paths_labels.yml.jinja /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /odoo/custom/ssh/id_rsa: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /odoo/custom/build.d/.empty: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /odoo/custom/conf.d/.empty: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /odoo/custom/src/addons.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /odoo/custom/ssh/id_rsa.pub: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /odoo/custom/dependencies/apt.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /odoo/custom/dependencies/gem.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /odoo/custom/dependencies/npm.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /odoo/custom/entrypoint.d/.empty: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /odoo/custom/dependencies/apt_build.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.docker/db-access.env.jinja: -------------------------------------------------------------------------------- 1 | PGPASSWORD={{ postgres_password }} 2 | -------------------------------------------------------------------------------- /.docker/odoo.env.jinja: -------------------------------------------------------------------------------- 1 | ADMIN_PASSWORD={{ odoo_admin_password }} 2 | -------------------------------------------------------------------------------- /.docker/db-creation.env.jinja: -------------------------------------------------------------------------------- 1 | POSTGRES_PASSWORD={{ postgres_password }} 2 | -------------------------------------------------------------------------------- /odoo/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ODOO_VERSION 2 | FROM ghcr.io/tecnativa/doodba:${ODOO_VERSION}-onbuild 3 | -------------------------------------------------------------------------------- /odoo/custom/ssh/config: -------------------------------------------------------------------------------- 1 | # See syntax in https://www.ssh.com/ssh/config/ and `man ssh_config` 2 | -------------------------------------------------------------------------------- /.docker/{% if smtp_relay_host %}smtp.env{% endif %}.jinja: -------------------------------------------------------------------------------- 1 | RELAY_PASSWORD={{ smtp_relay_password }} 2 | -------------------------------------------------------------------------------- /odoo/custom/src/private/.editorconfig.jinja: -------------------------------------------------------------------------------- 1 | {%- include "vendor/oca-addons-repo-template/.editorconfig" -%} 2 | -------------------------------------------------------------------------------- /docs/res/vscode_py_js_debug.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tecnativa/doodba-copier-template/HEAD/docs/res/vscode_py_js_debug.gif -------------------------------------------------------------------------------- /tasks.py.jinja: -------------------------------------------------------------------------------- 1 | {% if odoo_version < 11 -%} 2 | # -*- coding: utf-8 -*- 3 | {% endif -%} 4 | {% include "tasks_downstream.py" -%} 5 | -------------------------------------------------------------------------------- /.eslintrc.yml.jinja: -------------------------------------------------------------------------------- 1 | {%- include "vendor/oca-addons-repo-template/src/{% if odoo_version > 12 %}.eslintrc.yml{% endif %}.jinja" -%} 2 | -------------------------------------------------------------------------------- /{{ _copier_conf.answers_file }}.jinja: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY 2 | {{ _copier_answers|to_nice_yaml }} 3 | -------------------------------------------------------------------------------- /odoo/.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore all src directories except private and files with a dot 2 | custom/src/* 3 | !custom/src/private 4 | !custom/src/*.* 5 | auto 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "vendor/oca-addons-repo-template"] 2 | path = vendor/oca-addons-repo-template 3 | url = https://github.com/OCA/oca-addons-repo-template.git 4 | branch = master 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Question or general talk 4 | url: https://github.com/Tecnativa/doodba/discussions 5 | about: Please ask and answer questions here. 6 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | # Defaults for all prettier-supported languages 2 | # Prettier will complete this with settings from .editorconfig file. 3 | bracketSpacing: false 4 | printWidth: 88 5 | proseWrap: always 6 | semi: true 7 | trailingComma: "es5" 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | 8 | - package-ecosystem: pip 9 | directory: "/" 10 | schedule: 11 | interval: daily 12 | -------------------------------------------------------------------------------- /odoo/custom/build.d/{% if odoo_version < 11 %}10-fix-certs{% endif %}.jinja: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Remove revoked root certificate from list and update 3 | sed -i 's/mozilla\/DST_Root_CA_X3.crt/!mozilla\/DST_Root_CA_X3.crt/g' /etc/ca-certificates.conf 4 | update-ca-certificates 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{code-snippets,code-workspace,json,md,yaml,yml}{,.jinja}] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /odoo/custom/dependencies/pip.txt.jinja: -------------------------------------------------------------------------------- 1 | git+https://github.com/OCA/openupgradelib.git@master 2 | unicodecsv 3 | unidecode{% if odoo_version < 11.0 %}<1.3.0{% endif %} 4 | {% if odoo_version < 11.0 -%} 5 | pathlib 6 | {% endif -%} 7 | {% if 13.0 <= odoo_version < 16.0 -%} 8 | jingtrang 9 | {% endif -%} 10 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | usefixtures = versionless_odoo_autoskip 3 | addopts = -ra 4 | markers = 5 | sequential: marks tests that cannot run in parallel (deselect with '-m "not sequential"') 6 | skip_for_prereleases: marks tests that will be skipped for prereleases (deselect with '-m "not skip_for_prereleases"') 7 | -------------------------------------------------------------------------------- /.docker/{% if backup_dst %}backup.env{% endif %}.jinja: -------------------------------------------------------------------------------- 1 | #jinja2: lstrip_blocks: "true", trim_blocks: "true" 2 | {%- if backup_aws_access_key_id %} 3 | AWS_ACCESS_KEY_ID={{ backup_aws_access_key_id }} 4 | {%- endif %} 5 | {%- if backup_aws_secret_access_key %} 6 | AWS_SECRET_ACCESS_KEY={{ backup_aws_secret_access_key }} 7 | {%- endif %} 8 | PASSPHRASE={{ backup_passphrase }} 9 | -------------------------------------------------------------------------------- /odoo/custom/build.d/{% if odoo_version < 11 %}20-update-pg-repos{% endif %}.jinja: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # First one removes the current line 3 | echo "deb https://apt-archive.postgresql.org/pub/repos/apt jessie-pgdg-archive main" > /etc/apt/sources.list.d/postgresql.list 4 | # Second one appends 5 | echo "deb-src https://apt-archive.postgresql.org/pub/repos/apt jessie-pgdg-archive main" >> /etc/apt/sources.list.d/postgresql.list 6 | apt update 7 | -------------------------------------------------------------------------------- /{% if odoo_version <= 12 %}.flake8{% endif %}.jinja: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | max-complexity = 16 4 | # B = bugbear 5 | # B9 = bugbear opinionated (incl line length) 6 | select = C,E,F,W,B,B9 7 | # E203: whitespace before ':' (black behaviour) 8 | # E501: flake8 line length (covered by bugbear B950) 9 | # W503: line break before binary operator (black behaviour) 10 | ignore = E203,E501,W503,B907 11 | per-file-ignores= 12 | __init__.py:F401 13 | -------------------------------------------------------------------------------- /{% if odoo_version <= 12 %}.isort.cfg{% endif %}.jinja: -------------------------------------------------------------------------------- 1 | [settings] 2 | ; see https://github.com/psf/black 3 | multi_line_output=3 4 | include_trailing_comma=True 5 | force_grid_wrap=0 6 | combine_as_imports=True 7 | use_parentheses=True 8 | line_length=88 9 | known_odoo=odoo 10 | known_odoo_addons=odoo.addons 11 | sections=FUTURE,STDLIB,THIRDPARTY,ODOO,ODOO_ADDONS,FIRSTPARTY,LOCALFOLDER 12 | default_section=THIRDPARTY 13 | ensure_newline_before_comments = True 14 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "actboy168.tasks", 4 | "dbaeumer.vscode-eslint", 5 | "dchanco.vsc-invoke", 6 | "EditorConfig.editorconfig", 7 | "esbenp.prettier-vscode", 8 | "firefox-devtools.vscode-firefox-debug", 9 | "mrorz.language-gettext", 10 | "ms-azuretools.vscode-containers", 11 | "ms-python.black-formatter", 12 | "ms-python.python", 13 | "redhat.vscode-xml", 14 | "samuelcolvin.jinjahtml" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /odoo/custom/src/repos.yaml: -------------------------------------------------------------------------------- 1 | # See https://github.com/Tecnativa/doodba#optodoocustomsrcreposyaml 2 | ./odoo: 3 | defaults: 4 | # Shallow repositories ($DEPTH_DEFAULT=1) are faster & thinner 5 | # You may need a bigger depth when merging PRs (use $DEPTH_MERGE 6 | # for a sane value of 100 commits) 7 | depth: $DEPTH_DEFAULT 8 | remotes: 9 | ocb: https://github.com/OCA/OCB.git 10 | odoo: https://github.com/odoo/odoo.git 11 | openupgrade: https://github.com/OCA/OpenUpgrade.git 12 | target: ocb $ODOO_VERSION 13 | merges: 14 | - ocb $ODOO_VERSION 15 | # Example of a merge of the PR with the number 16 | # - oca refs/pull//head 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem?** 10 | 11 | 12 | 13 | **Describe the solution you'd like** 14 | 15 | 16 | 17 | **Describe alternatives you've considered** 18 | 19 | 20 | 21 | **Additional context** 22 | 23 | 24 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // More info on this file: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "Python: pytest", 7 | "type": "python", 8 | "request": "launch", 9 | "program": "${workspaceFolder}/.venv/bin/pytest", 10 | "args": ["tests"], 11 | "console": "integratedTerminal", 12 | "subProcess": true 13 | }, 14 | { 15 | "name": "Python: pytest verbose", 16 | "type": "python", 17 | "request": "launch", 18 | "program": "${workspaceFolder}/.venv/bin/pytest", 19 | "args": ["-vv", "tests"], 20 | "console": "integratedTerminal", 21 | "justMyCode": false, 22 | "subProcess": true 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore all src directories except private and files with a dot 2 | /odoo/auto 3 | /odoo/custom/src/*/ 4 | !/odoo/custom/src/private/ 5 | 6 | # Ignore docker-compose.yml and overrides by default, to allow easy defaults per clone 7 | /docker-compose.yml 8 | /docker-compose.override.yml 9 | 10 | # Compiled formats, cache, temporary files, git garbage 11 | **.~ 12 | **.mo 13 | **.py[co] 14 | **.egg-info 15 | **.orig 16 | 17 | # User-specific or editor-specific development files and settings 18 | .vscode/* 19 | !/.vscode/*.jinja 20 | !/.vscode/extensions.json 21 | !/.vscode/launch.json 22 | !/.vscode/settings.json 23 | !/.vscode/tasks.json 24 | /odoo/custom/src/private/.vscode/* 25 | /.venv 26 | /doodba.*.code-workspace 27 | /src 28 | .idea/* 29 | 30 | # Project-specific docker configurations 31 | /.docker/ 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | 11 | 12 | ## Describe the bug 13 | 14 | A clear and concise description of what the bug is. 15 | 16 | ## To Reproduce 17 | 18 | **Affected versions**: 19 | 20 | Steps to reproduce the behavior: 21 | 22 | 1. 23 | 2. 24 | 3. 25 | 26 | **Expected behavior** A clear and concise description of what you expected to happen. 27 | 28 | **Additional context** Add any other context about the problem here. (e.g. OS, Docker 29 | version, ...) 30 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | pull-requests: write 6 | contents: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v1.6.0 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: Enable auto-merge for Dependabot PRs 19 | if: 20 | ${{ steps.metadata.outputs.update-type == 'version-update:semver-patch' || 21 | steps.metadata.outputs.update-type == 'version-update:semver-minor' }} 22 | run: gh pr merge --auto --merge "$PR_URL" 23 | env: 24 | PR_URL: ${{ github.event.pull_request.html_url }} 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | -------------------------------------------------------------------------------- /.ruff.toml.jinja: -------------------------------------------------------------------------------- 1 | target-version = {% if odoo_version < 14 %}"py37"{% elif odoo_version < 16 %}"py38"{% else %}"py310"{% endif %} 2 | fix = true 3 | cache-dir = "~/.cache/ruff" 4 | {% set prefix = "lint." if odoo_version >= 18 else "" %} 5 | [lint] 6 | extend-select = [ 7 | "B", 8 | "C90", 9 | "E501", # line too long (default 88) 10 | "I", # isort 11 | "UP", # pyupgrade 12 | ] 13 | exclude = ["setup/*"] 14 | 15 | [format] 16 | exclude = ["setup/*"] 17 | 18 | [{{ prefix }}per-file-ignores] 19 | "__init__.py" = ["F401", "I001"] # ignore unused and unsorted imports in __init__.py 20 | "__manifest__.py" = ["B018"] # useless expression 21 | 22 | [{{ prefix }}isort] 23 | section-order = ["future", "standard-library", "third-party", "odoo", "odoo-addons", "first-party", "local-folder"] 24 | 25 | [{{ prefix }}isort.sections] 26 | "odoo" = ["odoo"] 27 | "odoo-addons" = ["odoo.addons"] 28 | 29 | [{{ prefix }}mccabe] 30 | max-complexity = 16 31 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Language-specific settings 3 | "[python]": { 4 | "editor.defaultFormatter": "ms-python.python" 5 | }, 6 | "[json]": { 7 | "editor.defaultFormatter": "esbenp.prettier-vscode" 8 | }, 9 | "[jsonc]": { 10 | "editor.defaultFormatter": "esbenp.prettier-vscode" 11 | }, 12 | "[markdown]": { 13 | "editor.defaultFormatter": "esbenp.prettier-vscode" 14 | }, 15 | "[yaml]": { 16 | "editor.defaultFormatter": "esbenp.prettier-vscode" 17 | }, 18 | 19 | // General settings 20 | "editor.formatOnSave": true, 21 | "markdown.extension.toc.updateOnSave": false, 22 | "python.formatting.blackArgs": [], 23 | "python.formatting.provider": "black", 24 | "python.linting.flake8Enabled": true, 25 | "python.linting.pylintEnabled": true, 26 | "python.defaultInterpreterPath": "${workspaceRoot}/.venv/bin/python", 27 | "python.testing.pytestArgs": ["-nauto", "tests"], 28 | "python.testing.pytestEnabled": true, 29 | "python.testing.unittestEnabled": false, 30 | "search.followSymlinks": false 31 | } 32 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | authors = ["Jairo Llopis "] 3 | description = "Copier template to maintain Doodba integration projects" 4 | license = "BSL-1.0" 5 | name = "doodba-copier-template" 6 | version = "0.1.0" 7 | package-mode = false 8 | 9 | [tool.poetry.dependencies] 10 | copier = ">=9" 11 | plumbum = "^1.8.2" 12 | python = "^3.8.1" 13 | 14 | [tool.poetry.dev-dependencies] 15 | black = "^24.8.0" 16 | flake8 = "^6.1.0" 17 | invoke = "^2.2.0" 18 | pylint = "^3.0.2" 19 | pytest = "^8.3.5" 20 | pytest-xdist = "^3.3.1" 21 | packaging = "^23.1" 22 | requests = "^2.32.4" 23 | pre-commit = "^3.3.3" 24 | python-on-whales = "^0.78.0" 25 | 26 | [tool.isort] 27 | # See https://github.com/psf/black 28 | combine_as_imports = true 29 | force_grid_wrap = 0 30 | include_trailing_comma = true 31 | line_length = 88 32 | multi_line_output = 3 33 | use_parentheses = true 34 | 35 | [build-system] 36 | build-backend = "poetry.masonry.api" 37 | requires = ["poetry>=0.12"] 38 | 39 | # ruff 40 | 41 | [tool.ruff] 42 | fix = true 43 | 44 | [tool.ruff.lint] 45 | extend-select = [ 46 | "UP", # pyupgrade 47 | "I", # isort 48 | ] 49 | -------------------------------------------------------------------------------- /.vscode/doodba.code-snippets: -------------------------------------------------------------------------------- 1 | // See spec in https://code.visualstudio.com/docs/editor/userdefinedsnippets 2 | { 3 | // See https://github.com/Tecnativa/doodba#optodoocustomsrcreposyaml 4 | "Git aggregator repo": { 5 | "prefix": "repo", 6 | "scope": "yaml", 7 | "body": [ 8 | "${10:repo-name}:", 9 | "\tdefaults:", 10 | "\t\tdepth: \\$DEPTH_MERGE", 11 | "\tremotes:", 12 | "\t\t${40:origin}: ${30:https://github.com/${20:OCA}/${10}.git}", 13 | "\ttarget: ${40} \\$ODOO_VERSION", 14 | "\tmerges:", 15 | "\t\t- ${40} \\$ODOO_VERSION", 16 | "\t\t- ${40} refs/pull/${50:1234}/head" 17 | ], 18 | "description": "Git-aggregator repo definition with merges" 19 | }, 20 | // See https://github.com/Tecnativa/doodba-copier-template/blob/main/docs/faq.md#how-can-i-whitelist-a-service-and-allow-external-access-to-it 21 | "Docker Whitelist Proxy": { 22 | "prefix": "proxy", 23 | "scope": "yaml", 24 | "body": [ 25 | "${10:proxy_}:", 26 | "\timage: ghcr.io/tecnativa/docker-whitelist:latest", 27 | "\tnetworks:", 28 | "\t\tdefault:", 29 | "\t\t\taliases:", 30 | "\t\t\t\t- ${20:URL}", 31 | "\t\tpublic:", 32 | "\tenvironment:", 33 | "\t\tTARGET: ${20}", 34 | "\t\tPRE_RESOLVE: 1" 35 | ], 36 | "description": "Docker Whitelist definition" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Boost Software License - Version 1.0 - August 17th, 2003 2 | 3 | Permission is hereby granted, free of charge, to any person or organization 4 | obtaining a copy of the software and accompanying documentation covered by 5 | this license (the "Software") to use, reproduce, display, distribute, 6 | execute, and transmit the Software, and to prepare derivative works of the 7 | Software, and to permit third-parties to whom the Software is furnished to 8 | do so, all subject to the following: 9 | 10 | The copyright notices in the Software and this entire statement, including 11 | the above license grant, this restriction and the following disclaimer, 12 | must be included in all copies of the Software, in whole or in part, and 13 | all derivative works of the Software, unless such copies or derivative 14 | works are solely in the form of machine-executable object code generated by 15 | a source language processor. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT 20 | SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE 21 | FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, 22 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /setup-devel.yaml.jinja: -------------------------------------------------------------------------------- 1 | {% import "_macros.jinja" as macros -%} 2 | # Use this environment to download all repositories from `repos.yaml` file: 3 | # 4 | # export DOODBA_GITAGGREGATE_UID="$(id -u $USER)" DOODBA_GITAGGREGATE_GID="$(id -g $USER)" DOODBA_UMASK="$(umask)" 5 | # docker-compose -f setup-devel.yaml run --rm odoo 6 | # 7 | # You can clean your git project before if you want to have all really clean: 8 | # 9 | # git clean -ffd 10 | 11 | version: "2.4" 12 | 13 | services: 14 | odoo: 15 | {%- if odoo_oci_image %} 16 | image: {{ odoo_oci_image }}:{{ macros.version_minor(odoo_version) }} 17 | {%- endif %} 18 | build: 19 | context: ./odoo 20 | args: 21 | AGGREGATE: "false" 22 | DEPTH_DEFAULT: 100 23 | ODOO_VERSION: "{{ macros.version_minor(odoo_version) }}" 24 | PYTHONOPTIMIZE: "" 25 | PIP_INSTALL_ODOO: "false" 26 | CLEAN: "false" 27 | COMPILE: "false" 28 | UID: "${UID:-1000}" 29 | GID: "${GID:-1000}" 30 | networks: 31 | - public 32 | volumes: 33 | - ./odoo/custom/src:/opt/odoo/custom/src:rw,z 34 | environment: 35 | DEPTH_DEFAULT: 100 36 | # XXX Export these variables before running setup to own the files 37 | UID: "${DOODBA_GITAGGREGATE_UID:-1000}" 38 | GID: "${DOODBA_GITAGGREGATE_GID:-1000}" 39 | UMASK: "$DOODBA_UMASK" 40 | user: root 41 | entrypoint: autoaggregate 42 | 43 | networks: 44 | public: 45 | -------------------------------------------------------------------------------- /tests/test_doodba_qa.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from copier import run_copy 4 | from python_on_whales import DockerClient 5 | 6 | from .conftest import DBVER_PER_ODOO 7 | 8 | 9 | def test_doodba_qa(tmp_path: Path, supported_odoo_version: float): 10 | """Test Doodba QA works fine with a scaffolding copy.""" 11 | run_copy( 12 | ".", 13 | tmp_path, 14 | data={ 15 | "odoo_version": supported_odoo_version, 16 | "postgres_version": DBVER_PER_ODOO[supported_odoo_version]["latest"], 17 | }, 18 | vcs_ref="HEAD", 19 | defaults=True, 20 | overwrite=True, 21 | unsafe=True, 22 | ) 23 | docker = DockerClient() 24 | 25 | def _execute_qa(cmd): 26 | return docker.run( 27 | "tecnativa/doodba-qa", 28 | command=cmd, 29 | envs={ 30 | "ADDON_CATEGORIES": "-p", 31 | "COMPOSE_FILE": "test.yaml", 32 | "ODOO_VERSION": supported_odoo_version, 33 | }, 34 | privileged=True, 35 | remove=True, 36 | volumes=[ 37 | (tmp_path, tmp_path, "z"), 38 | ("/var/run/docker.sock", "/var/run/docker.sock", "z"), 39 | ], 40 | workdir=tmp_path, 41 | ) 42 | 43 | try: 44 | _execute_qa(["secrets-setup"]) 45 | _execute_qa(["networks-autocreate"]) 46 | _execute_qa(["build"]) 47 | _execute_qa(["closed-prs"]) 48 | _execute_qa(["flake8"]) 49 | _execute_qa(["pylint"]) 50 | _execute_qa(["addons-install"]) 51 | _execute_qa(["coverage"]) 52 | finally: 53 | _execute_qa(["shutdown"]) 54 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3 3 | node: "18.17.1" 4 | exclude: ^tests/samples/mqt-diffs/.* 5 | repos: 6 | - repo: local 7 | hooks: 8 | - id: poetry-check 9 | name: poetry check 10 | entry: poetry 11 | args: ["check"] 12 | files: pyproject\.toml$ 13 | pass_filenames: false 14 | language: system 15 | - id: forbidden-files 16 | name: forbidden files 17 | entry: found forbidden files; remove them 18 | language: fail 19 | files: "\\.rej$" 20 | - repo: https://github.com/astral-sh/ruff-pre-commit 21 | rev: v0.1.3 22 | hooks: 23 | - id: ruff 24 | args: [--exit-non-zero-on-fix] 25 | - id: ruff-format 26 | - repo: https://github.com/asottile/pyupgrade 27 | rev: v3.10.1 28 | hooks: 29 | - id: pyupgrade 30 | args: 31 | - --keep-percent-format 32 | - repo: https://github.com/pre-commit/mirrors-prettier 33 | rev: v3.0.2 34 | hooks: 35 | - id: prettier 36 | args: [--ignore-unknown] 37 | - repo: https://github.com/pre-commit/pre-commit-hooks 38 | rev: v4.5.0 39 | hooks: 40 | - id: trailing-whitespace 41 | - id: end-of-file-fixer 42 | - id: debug-statements 43 | - id: check-case-conflict 44 | - id: check-docstring-first 45 | - id: check-executables-have-shebangs 46 | - id: check-merge-conflict 47 | - id: check-symlinks 48 | - id: check-xml 49 | - id: mixed-line-ending 50 | args: ["--fix=lf"] 51 | - repo: https://github.com/thlorenz/doctoc 52 | rev: v2.2.0 53 | hooks: 54 | - id: doctoc 55 | args: 56 | - --github 57 | - --maxlevel=6 58 | - --title=Table of contents 59 | exclude: ^(tests/|.github/ISSUE_TEMPLATE/) 60 | -------------------------------------------------------------------------------- /docs/scaffolding2copier.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Simple script to ease migration from doodba-scaffolding to doodba-copier-template. 4 | # Configure it exporting these environment variables: 5 | # - `CUSTOM_COPIER_FLAGS` lets you configure this transition. Use `--force` to avoid 6 | # Copier asking you things, if you are confident in the answers provided here. 7 | # - `TEMPLATE_VERSION` the only supported version to migrate from doodba-scaffolding 8 | # is the default one set here, but you can use others at your own risk. 9 | # - `GITLAB_PREFIX` 10 | # - `LICENSE` 11 | # - `PROJECT_NAME` defaults to the current folder name. 12 | 13 | source .env 14 | copier $CUSTOM_COPIER_FLAGS \ 15 | -r "${TEMPLATE_VERSION-v1.8.1}" \ 16 | -d project_name="${PROJECT_NAME-$(basename $PWD)}" \ 17 | -d project_license="${LICENSE-BSL-1.0}" \ 18 | -d gitlab_url="${GITLAB_PREFIX-https://gitlab.com/example}/${PROJECT_NAME-$(basename $PWD)}" \ 19 | -d domain_prod="$DOMAIN_PROD" \ 20 | -d domain_prod_alternatives="[$DOMAIN_PROD_ALT]" \ 21 | -d domain_test="$DOMAIN_TEST" \ 22 | -d odoo_version="$ODOO_MINOR" \ 23 | -d odoo_initial_lang="$INITIAL_LANG" \ 24 | -d odoo_oci_image="$ODOO_IMAGE" \ 25 | -d odoo_dbfilter="$DB_FILTER" \ 26 | -d odoo_proxy=traefik \ 27 | -d postgres_version="$DB_VERSION" \ 28 | -d postgres_username="$DB_USER" \ 29 | -d postgres_dbname="prod" \ 30 | -d traefik_version="$TRAEFIK_VERSION" \ 31 | -d smtp_default_from="$SMTP_DEFAULT_FROM" \ 32 | -d smtp_relay_host="$SMTP_REAL_RELAY_HOST" \ 33 | -d smtp_relay_port="$SMTP_REAL_RELAY_PORT" \ 34 | -d smtp_relay_user="$SMTP_REAL_RELAY_USER" \ 35 | -d smtp_canonical_default="$SMTP_REAL_NON_CANONICAL_DEFAULT" \ 36 | -d smtp_canonical_domains="[$SMTP_REAL_CANONICAL_DOMAINS]" \ 37 | -d backup_dst="boto3+s3://$BACKUP_S3_BUCKET" \ 38 | -d backup_email_from="$BACKUP_EMAIL_FROM" \ 39 | -d backup_email_to="$BACKUP_EMAIL_TO" \ 40 | -d backup_deletion=false \ 41 | -d backup_tz="$BACKUP_TZ" \ 42 | update 43 | -------------------------------------------------------------------------------- /README.md.jinja: -------------------------------------------------------------------------------- 1 | {%- import "_macros.jinja" as macros -%} 2 | [![Doodba deployment](https://img.shields.io/badge/deployment-doodba-informational)](https://github.com/Tecnativa/doodba) 3 | [![Last template update](https://img.shields.io/badge/last%20template%20update-{{ _copier_answers._commit|replace('-', '--') }}-informational)](https://github.com/Tecnativa/doodba-copier-template/tree/{{ _copier_answers._commit }}) 4 | [![Odoo](https://img.shields.io/badge/odoo-v{{ odoo_version }}-a3478a)](https://github.com/odoo/odoo/tree/{{ odoo_version }}) 5 | {%- if gitlab_url %} 6 | [![pipeline status]({{ gitlab_url }}/badges/{{ odoo_version }}/pipeline.svg)]({{ gitlab_url }}/commits/{{ odoo_version }}) 7 | [![coverage report]({{ gitlab_url }}/badges/{{ odoo_version }}/coverage.svg)]({{ gitlab_url }}/commits/{{ odoo_version }}) 8 | {%- endif %} 9 | {%- if domains_prod %} 10 | [![Deployment data](https://img.shields.io/badge/%F0%9F%8C%90%20prod-{{ macros.first_main_domain(domains_prod)|replace('-', '--') }}-green)](http://{{ macros.first_main_domain(domains_prod) }}) 11 | {%- endif %} 12 | {%- if domains_test %} 13 | [![Deployment data](https://img.shields.io/badge/%E2%9A%92%20demo-{{ macros.first_main_domain(domains_test)|replace('-', '--') }}-yellow)](http://{{ macros.first_main_domain(domains_test) }}) 14 | {%- endif %} 15 | {%- if project_license != "no_license" %} 16 | [![{{ project_license }} license](https://img.shields.io/badge/license-{{ project_license|replace("-", "--") }}-{%- if project_license in ["OEEL-1.0", "OPL-1.0"] %}critical{%- else %}success{%- endif %}})](LICENSE) 17 | {%- endif %} 18 | [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://pre-commit.com/) 19 | 20 | # {{ project_name }} - a Doodba deployment 21 | 22 | This project is a Doodba scaffolding. Check upstream docs on the matter: 23 | 24 | - [General Doodba docs](https://github.com/Tecnativa/doodba). 25 | - [Doodba copier template docs](https://github.com/Tecnativa/doodba-copier-template) 26 | - [Doodba QA docs](https://github.com/Tecnativa/doodba-qa) 27 | 28 | # Credits 29 | 30 | This project is maintained by: 31 | {%- if project_author == "Tecnativa" %} 32 | 33 | [![Tecnativa](https://www.tecnativa.com/r/H3p)](https://www.tecnativa.com/r/bb4) 34 | 35 | Also, special thanks to 36 | [our dear community contributors](https://github.com/Tecnativa/doodba-copier-template/graphs/contributors). 37 | 38 | {%- else %} {{ project_author }}{% endif %} 39 | -------------------------------------------------------------------------------- /odoo/custom/ssh/known_hosts: -------------------------------------------------------------------------------- 1 | # Use `ssh-keyscan` to fill this file and ensure remote git hosts ssh keys 2 | 3 | # bitbucket.org:22 SSH-2.0-conker_8c537ded9a e1148f0abe57 4 | bitbucket.org ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBPIQmuzMBuKdWeF4+a2sjSSpBK0iqitSQ+5BM9KhpexuGt20JpTVM7u5BDZngncgrqDMbWdxMWWOGtZ9UgbqgZE= 5 | # bitbucket.org:22 SSH-2.0-conker_8c537ded9a 66c29d7ad5be 6 | bitbucket.org ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDQeJzhupRu0u0cdegZIa8e86EG2qOCsIsD1Xw0xSeiPDlCr7kq97NLmMbpKTX6Esc30NuoqEEHCuc7yWtwp8dI76EEEB1VqY9QJq6vk+aySyboD5QF61I/1WeTwu+deCbgKMGbUijeXhtfbxSxm6JwGrXrhBdofTsbKRUsrN1WoNgUa8uqN1Vx6WAJw1JHPhglEGGHea6QICwJOAr/6mrui/oB7pkaWKHj3z7d1IC4KWLtY47elvjbaTlkN04Kc/5LFEirorGYVbt15kAUlqGM65pk6ZBxtaO3+30LVlORZkxOh+LKL/BvbZ/iRNhItLqNyieoQj/uh/7Iv4uyH/cV/0b4WDSd3DptigWq84lJubb9t/DnZlrJazxyDCulTmKdOR7vs9gMTo+uoIrPSb8ScTtvw65+odKAlBj59dhnVp9zd7QUojOpXlL62Aw56U4oO+FALuevvMjiWeavKhJqlR7i5n9srYcrNV7ttmDw7kf/97P5zauIhxcjX+xHv4M= 7 | # bitbucket.org:22 SSH-2.0-conker_8c537ded9a 551826d0fde9 8 | bitbucket.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIazEu89wgQZ4bqs3d63QSMzYVa0MuJ2e2gKTKqu+UUO 9 | 10 | # github.com:22 SSH-2.0-libssh-0.7.0 11 | github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk= 12 | 13 | # gitlab.com:22 SSH-2.0-OpenSSH_7.2p2 Ubuntu-4ubuntu2.2 14 | gitlab.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9 15 | # gitlab.com:22 SSH-2.0-OpenSSH_7.2p2 Ubuntu-4ubuntu2.2 16 | gitlab.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY= 17 | # gitlab.com:22 SSH-2.0-OpenSSH_7.2p2 Ubuntu-4ubuntu2.2 18 | gitlab.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf 19 | -------------------------------------------------------------------------------- /tests/test_postgres.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from pathlib import Path 3 | 4 | import pytest 5 | from copier import run_copy 6 | from plumbum import local 7 | from python_on_whales import DockerClient 8 | 9 | from .conftest import DBVER_PER_ODOO 10 | 11 | 12 | @pytest.mark.parametrize("dbver", ("oldest", "latest")) 13 | def test_postgresql_client_versions( 14 | cloned_template: Path, 15 | supported_odoo_version: float, 16 | tmp_path: Path, 17 | dbver: str, 18 | ): 19 | """Test multiple postgresql-client versions in odoo, db and duplicity services""" 20 | dbver_raw = DBVER_PER_ODOO[supported_odoo_version][dbver] 21 | dbver_mver = dbver_raw.split(".")[0] 22 | dc_prod = DockerClient(compose_files=["prod.yaml"]) 23 | with local.cwd(tmp_path): 24 | print(str(cloned_template)) 25 | assert True 26 | run_copy( 27 | str(cloned_template), 28 | dst_path=".", 29 | data={ 30 | "odoo_version": supported_odoo_version, 31 | "project_name": uuid.uuid4().hex, 32 | "odoo_proxy": "", 33 | "postgres_version": dbver_raw, 34 | "backup_dst": "/tmp/dummy", 35 | }, 36 | vcs_ref="test", 37 | defaults=True, 38 | overwrite=True, 39 | unsafe=True, 40 | ) 41 | try: 42 | dc_prod.compose.build() 43 | odoo_pgdump_stdout = dc_prod.compose.run( 44 | "odoo", 45 | command=["pg_dump", "--version"], 46 | remove=True, 47 | tty=False, 48 | ) 49 | odoo_pgdump_mver = ( 50 | odoo_pgdump_stdout.splitlines()[-1].strip().split(" ")[2].split(".")[0] 51 | ) 52 | db_pgdump_stdout = dc_prod.compose.run( 53 | "db", 54 | command=["pg_dump", "--version"], 55 | remove=True, 56 | tty=False, 57 | ) 58 | db_pgdump_mver = ( 59 | db_pgdump_stdout.splitlines()[-1].strip().split(" ")[2].split(".")[0] 60 | ) 61 | backup_pgdump_stdout = dc_prod.compose.run( 62 | "backup", 63 | command=["pg_dump", "--version"], 64 | remove=True, 65 | tty=False, 66 | ) 67 | backup_pgdump_mver = ( 68 | backup_pgdump_stdout.splitlines()[-1] 69 | .strip() 70 | .split(" ")[2] 71 | .split(".")[0] 72 | ) 73 | assert ( 74 | odoo_pgdump_mver == db_pgdump_mver == backup_pgdump_mver == dbver_mver 75 | ) 76 | finally: 77 | dc_prod.compose.rm(stop=True, volumes=True) 78 | -------------------------------------------------------------------------------- /.pylintrc-mandatory.jinja: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | load-plugins=pylint_odoo 3 | score=n 4 | 5 | [ODOOLINT] 6 | {%- if odoo_version < 16 %} 7 | readme_template_url="https://github.com/OCA/maintainer-tools/blob/master/template/module/README.rst" 8 | manifest_required_keys=license 9 | manifest_deprecated_keys=active 10 | valid_odoo_versions={{ odoo_version }} 11 | {%- else %} 12 | readme-template-url="https://github.com/OCA/maintainer-tools/blob/master/template/module/README.rst" 13 | manifest-required-keys=license 14 | manifest-deprecated-keys=active 15 | valid-odoo-versions={{ odoo_version }} 16 | {%- endif %} 17 | 18 | [MESSAGES CONTROL] 19 | disable=all 20 | 21 | 22 | enable=attribute-deprecated, 23 | manifest-author-string, 24 | manifest-deprecated-key, 25 | manifest-required-key, 26 | manifest-version-format, 27 | method-compute, 28 | method-inverse, 29 | method-required-super, 30 | method-search, 31 | print-used, 32 | sql-injection, 33 | translation-field, 34 | translation-required, 35 | use-vim-comment, 36 | {%- if odoo_version < 16 %} 37 | anomalous-backslash-in-string, 38 | api-one-deprecated, 39 | api-one-multi-together, 40 | assignment-from-none, 41 | class-camelcase, 42 | dangerous-default-value, 43 | dangerous-view-replace-wo-priority, 44 | duplicate-id-csv, 45 | duplicate-key, 46 | duplicate-xml-fields, 47 | duplicate-xml-record-id, 48 | eval-referenced, 49 | eval-used, 50 | incoherent-interpreter-exec-perm, 51 | missing-import-error, 52 | missing-manifest-dependency, 53 | openerp-exception-warning, 54 | pointless-statement, 55 | pointless-string-statement, 56 | redundant-keyword-arg, 57 | redundant-modulename-xml, 58 | reimported, 59 | relative-import, 60 | return-in-init, 61 | rst-syntax-error, 62 | too-few-format-args, 63 | unreachable, 64 | wrong-tabs-instead-of-spaces, 65 | xml-syntax-error 66 | {%- else %} 67 | attribute-string-redundant, 68 | bad-builtin-groupby, 69 | context-overridden, 70 | external-request-timeout, 71 | manifest-data-duplicated, 72 | missing-return, 73 | no-raise-unlink, 74 | no-wizard-in-models, 75 | no-write-in-compute, 76 | odoo-exception-warning, 77 | renamed-field-parameter, 78 | resource-not-exist, 79 | test-folder-imported, 80 | translation-contains-variable, 81 | translation-format-interpolation, 82 | translation-format-truncated, 83 | translation-fstring-interpolation, 84 | translation-not-lazy, 85 | translation-positional-used, 86 | translation-too-few-args, 87 | translation-too-many-args, 88 | translation-unsupported-format 89 | {%- endif %} 90 | 91 | [REPORTS] 92 | msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg} 93 | output-format=colorized 94 | reports=no 95 | -------------------------------------------------------------------------------- /_macros.jinja: -------------------------------------------------------------------------------- 1 | {# Odoo version formatted without dot #} 2 | {%- macro version_major(odoo_version) %} 3 | {{- "%.0f"|format(odoo_version) }} 4 | {%- endmacro %} 5 | 6 | {# Odoo version formatted with one single dot #} 7 | {%- macro version_minor(odoo_version) %} 8 | {{- "%.1f"|format(odoo_version) }} 9 | {%- endmacro %} 10 | 11 | {# Loop over domain group lists, and call back with the domain_group variable. #} 12 | {%- macro domains_loop_grouped(domain_groups_list) %} 13 | {%- set domain_group = namespace(exit=false) %} 14 | {%- for _domain_group in domain_groups_list|default([], true) %} 15 | {%- if not domain_group.exit %} 16 | {%- set domain_group.cert_resolver = _domain_group.cert_resolver|default("letsencrypt") %} 17 | {%- set domain_group.entrypoints = _domain_group.entrypoints|default([], true) %} 18 | {%- set domain_group.hosts = _domain_group.hosts|default([], true) %} 19 | {%- set domain_group.path_prefixes = _domain_group.path_prefixes|default([], true) %} 20 | {%- set domain_group.redirect_to = _domain_group.redirect_to|default(none) %} 21 | {%- set domain_group.redirect_permanent = _domain_group.redirect_permanent|default(False) %} 22 | {%- set domain_group.loop = loop %} 23 | {{- caller(domain_group) }} 24 | {%- endif %} 25 | {%- endfor %} 26 | {%- endmacro %} 27 | 28 | {# Loop over domain group lists and call back with a single domain. #} 29 | {%- macro domains_loop_single(domain_groups_list) %} 30 | {%- set domain = namespace(exit=false, index0=0, index=1) %} 31 | {%- set parent_caller = caller %} 32 | {%- call(domain_group) domains_loop_grouped(domain_groups_list) %} 33 | {%- set domain.group = domain_group %} 34 | {%- for host in domain_group.hosts|default([], true) %} 35 | {%- if not domain.exit %} 36 | {%- set domain.host = host %} 37 | {%- set domain.loop = loop %} 38 | {{- parent_caller(domain) }} 39 | {%- set domain.index = domain.index + 1 %} 40 | {%- set domain.index0 = domain.index0 + 1 %} 41 | {%- endif %} 42 | {%- endfor %} 43 | {%- endcall %} 44 | {%- endmacro %} 45 | 46 | {# Get the main domain from a domain groups list. 47 | 48 | The main domain is the 1st one found from a routing rule that redirects nowhere 49 | and uses no path prefix. 50 | 51 | This macro just prints that hostname. 52 | #} 53 | {%- macro first_main_domain(domain_groups_list) %} 54 | {%- call(domain) domains_loop_single(domain_groups_list) %} 55 | {%- if not domain.group.redirect_to and not domain.group.path_prefixes %} 56 | {{- domain.host }} 57 | {%- set domain.exit = true %} 58 | {%- endif %} 59 | {%- endcall %} 60 | {%- endmacro %} 61 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | """Template maintenance tasks. 2 | 3 | These tasks are to be executed with https://www.pyinvoke.org/ in Python 3.8.1+ 4 | and are related to the maintenance of this template project, not the child 5 | projects generated with it. 6 | """ 7 | import re 8 | from pathlib import Path 9 | from unittest import mock 10 | 11 | from invoke import task 12 | from invoke.util import yaml 13 | 14 | TEMPLATE_ROOT = Path(__file__).parent.resolve() 15 | ESSENTIALS = ("git", "python3", "poetry") 16 | 17 | 18 | def _load_copier_conf(): 19 | """Load copier.yml.""" 20 | with open("copier.yml") as copier_fd: 21 | # HACK https://stackoverflow.com/a/44875714/1468388 22 | # TODO Remove hack when https://github.com/pyinvoke/invoke/issues/708 is fixed 23 | with mock.patch.object( 24 | yaml.reader.Reader, 25 | "NON_PRINTABLE", 26 | re.compile( 27 | "[^\x09\x0A\x0D\x20-\x7E\x85\xA0-" 28 | "\uD7FF\uE000-\uFFFD\U00010000-\U0010FFFF]" 29 | ), 30 | ): 31 | return yaml.safe_load(copier_fd) 32 | 33 | 34 | @task 35 | def check_dependencies(c): 36 | """Check essential development dependencies are present.""" 37 | failures = [] 38 | for dependency in ESSENTIALS: 39 | try: 40 | c.run(f"{dependency} --version", hide=True) 41 | except Exception: 42 | failures.append(dependency) 43 | if failures: 44 | print(f"Missing essential dependencies: {failures}") 45 | 46 | 47 | @task(check_dependencies) 48 | def develop(c): 49 | """Set up a development environment.""" 50 | with c.cd(str(TEMPLATE_ROOT)): 51 | c.run("git submodule update --init --checkout --recursive") 52 | # Use poetry to set up development environment in a local venv 53 | c.run("poetry install") 54 | c.run("poetry run pre-commit install") 55 | 56 | 57 | @task(develop) 58 | def lint(c, verbose=False): 59 | """Lint & format source code.""" 60 | flags = ["--show-diff-on-failure", "--all-files", "--color=always"] 61 | if verbose: 62 | flags.append("--verbose") 63 | flags = " ".join(flags) 64 | with c.cd(str(TEMPLATE_ROOT)): 65 | c.run(f"poetry run pre-commit run {flags}") 66 | 67 | 68 | @task(develop) 69 | def test(c, verbose=False, sequential=False, docker=True): 70 | """Test project. 71 | 72 | Add --sequential to run only sequential tests, with parallelization disabled. 73 | """ 74 | flags = ["--color=yes"] 75 | if verbose: 76 | flags.append("-vv") 77 | if not docker: 78 | flags.append("--skip-docker-tests") 79 | if sequential: 80 | flags.extend(["-m", "sequential"]) 81 | else: 82 | flags.extend(["-n", "auto", "-m", '"not sequential"']) 83 | flags = " ".join(flags) 84 | cmd = f"poetry run pytest {flags} tests" 85 | with c.cd(str(TEMPLATE_ROOT)): 86 | c.run(cmd) 87 | -------------------------------------------------------------------------------- /tests/test_default_settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from shutil import rmtree 3 | 4 | import pytest 5 | import yaml 6 | from copier.main import run_copy 7 | from plumbum import local 8 | from plumbum.cmd import git, invoke 9 | 10 | from .conftest import DBVER_PER_ODOO 11 | 12 | 13 | def test_default_settings( 14 | tmp_path: Path, supported_odoo_version: float, cloned_template: Path 15 | ): 16 | """Test that a template can be rendered from zero for each version.""" 17 | with local.cwd(cloned_template): 18 | run_copy( 19 | ".", 20 | str(tmp_path), 21 | data={"odoo_version": supported_odoo_version}, 22 | vcs_ref="test", 23 | defaults=True, 24 | overwrite=True, 25 | unsafe=True, 26 | ) 27 | with local.cwd(tmp_path): 28 | # TODO When copier runs pre-commit before extracting diff, make sure 29 | # here that it works as expected 30 | Path(tmp_path, "odoo", "auto", "addons").rmdir() 31 | Path(tmp_path, "odoo", "auto").rmdir() 32 | git("add", ".") 33 | git("commit", "-am", "Hello World", retcode=1) # pre-commit fails 34 | git("commit", "-am", "Hello World") 35 | 36 | 37 | def test_pre_commit_autoinstall( 38 | cloned_template: Path, tmp_path: Path, supported_odoo_version: float 39 | ): 40 | """Test that pre-commit is automatically (un)installed in alien repos. 41 | 42 | This test is slower because it has to download and build OCI images and 43 | download git code, so it's only executed against these Odoo versions: 44 | 45 | - 10.0 because it's Python 2 and has no pre-commit configurations in OCA. 46 | - 13.0 because it's Python 3 and has pre-commit configurations in OCA. 47 | """ 48 | if supported_odoo_version not in {10.0, 13.0}: 49 | pytest.skip("this test is only tested with other odoo versions") 50 | run_copy( 51 | str(cloned_template), 52 | str(tmp_path), 53 | data={ 54 | "odoo_version": supported_odoo_version, 55 | "postgres_version": DBVER_PER_ODOO[supported_odoo_version]["latest"], 56 | }, 57 | vcs_ref="HEAD", 58 | defaults=True, 59 | overwrite=True, 60 | unsafe=True, 61 | ) 62 | with local.cwd(tmp_path): 63 | with (tmp_path / "odoo" / "custom" / "src" / "addons.yaml").open("w") as fd: 64 | yaml.dump({"server-tools": "*"}, fd) 65 | # User can download git code from any folder 66 | with local.cwd(tmp_path / "odoo" / "custom" / "src" / "private"): 67 | invoke("git-aggregate") 68 | # Check pre-commit is properly (un)installed 69 | pre_commit_present = supported_odoo_version >= 13.0 70 | server_tools_git = ( 71 | tmp_path / "odoo" / "custom" / "src" / "server-tools" / ".git" 72 | ) 73 | assert server_tools_git.is_dir() 74 | assert ( 75 | server_tools_git / "hooks" / "pre-commit" 76 | ).is_file() == pre_commit_present 77 | # Remove source code, it can use a lot of disk space 78 | rmtree(tmp_path) 79 | -------------------------------------------------------------------------------- /.module-readme.rst.j2: -------------------------------------------------------------------------------- 1 | {%- macro fragment(name, title, sub='=') %} 2 | {%- if name in fragments %} 3 | {{- title }} 4 | {{ sub * title|length }} 5 | 6 | {{ fragments[name] }} 7 | {% endif %} 8 | {%- endmacro -%} 9 | 10 | {{ '=' * manifest.name|length }} 11 | {{ manifest.name }} 12 | {{ '=' * manifest.name|length }} 13 | 14 | .. 15 | !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 16 | !! This file is generated by oca-gen-addon-readme !! 17 | !! changes will be overwritten. !! 18 | !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 19 | !! source digest: {{ source_digest }} 20 | !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 21 | 22 | .. |badge_devstat| image:: https://img.shields.io/badge/maturity-{{ development_status | replace("-", "--") | urlencode }}-brightgreen.png 23 | :target: https://odoo-community.org/page/development-status 24 | :alt: {{ development_status | title }} 25 | 26 | .. |badge_license| image:: https://img.shields.io/badge/license-{{ manifest.license | d("Unknown") | replace("-", "--") | urlencode }}-blue.png 27 | :alt: {{ manifest.license | d("Unknown") }} 28 | 29 | |badge_devstat| |badge_license| 30 | 31 | {{ fragments.get('DESCRIPTION', '') }} 32 | {% if development_status == 'alpha' -%} 33 | 34 | .. IMPORTANT:: 35 | This is an alpha version, the data model and design can change at any time without warning. 36 | Only for development or testing purpose, do not use in production. 37 | `More details on development status `_ 38 | 39 | {% endif -%} 40 | 41 | **Table of contents** 42 | 43 | .. contents:: 44 | :local: 45 | 46 | {{ fragment('CONTEXT', 'Use Cases / Context') }} 47 | {{- fragment('INSTALL', 'Installation') }} 48 | {{- fragment('CONFIGURE', 'Configuration') }} 49 | {{- fragment('USAGE', 'Usage') }} 50 | {{- fragment('DEVELOP', 'Development') }} 51 | {{- fragment('ROADMAP', 'Known issues / Roadmap') -}} 52 | {{- fragment('HISTORY', 'Changelog') -}} 53 | 54 | Credits 55 | {#- HACK Avoid conflicts with pre-commit's check-merge-conflict #} 56 | {{ "=======" }} 57 | 58 | {% if authors -%} 59 | Authors 60 | {{ level3_underline * 7 }} 61 | 62 | {% for author in authors if author -%} 63 | * {{ author }} 64 | {% endfor %} 65 | {% endif -%} 66 | 67 | {{ fragment('CONTRIBUTORS', 'Contributors', sub=level3_underline) }} 68 | {{- fragment('CREDITS', 'Other credits', sub=level3_underline) -}} 69 | Maintainers 70 | {{ level3_underline * 11 }} 71 | 72 | This module is maintained by {{ org_name }}. 73 | 74 | Contact the maintainer through their official support channels in case you find 75 | any issues with this module. 76 | {% if manifest.maintainers %} 77 | 78 | {% for maintainer in manifest.maintainers %} 79 | .. |maintainer-{{ maintainer }}| image:: https://github.com/{{ maintainer }}.png?size=40px 80 | :target: https://github.com/{{ maintainer }} 81 | :alt: {{ maintainer}} 82 | {%- endfor %} 83 | 84 | Current maintainer{% if manifest.maintainers|length > 1 %}s{% endif %}: 85 | 86 | {% for maintainer in manifest.maintainers %}|maintainer-{{ maintainer }}|{% if not loop.last %} {% endif %}{% endfor %} 87 | {% endif %} 88 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 |
4 | 5 | 6 | 7 | Table of contents 8 | 9 | - [General Discussion](#general-discussion) 10 | - [Issues](#issues) 11 | - [Propose Changes](#propose-changes) 12 | - [Set up a Development Environment](#set-up-a-development-environment) 13 | - [Know our Development Toolkit](#know-our-development-toolkit) 14 | - [Open a Pull Request](#open-a-pull-request) 15 | 16 | 17 | 18 |
19 | 20 | You should know how to use Github to contribute to this project. To learn, please follow 21 | these tutorials: 22 | 23 | - [Introduction to GitHub](https://lab.github.com/githubtraining/introduction-to-github) 24 | 25 | Now that you know how to use Github, we just follow the standard process like everybody 26 | else here: issues and pull requests. 27 | 28 | ## General Discussion 29 | 30 | Please make use of [Doodba discussions](https://github.com/Tecnativa/doodba/discussions) 31 | to share knowledge, ideas or make questions. 32 | 33 | There's one concrete 34 | [channel for stuff specifically related to this template](https://github.com/Tecnativa/doodba/discussions?discussions_q=category%3A%22Doodba+-+The+template%22). 35 | 36 | ## Issues 37 | 38 | First of all, make sure your problem or suggestion is related to doodba-copier-template. 39 | 40 | If that's the case, open an issue in our Github project. 41 | [Read the instructions](https://help.github.com/en/github/managing-your-work-on-github/creating-an-issue) 42 | to know how to do it. 43 | 44 | ## Propose Changes 45 | 46 | ### Set up a Development Environment 47 | 48 | To hack in this project, you need to set up a development environment. To do that, first 49 | make sure you have installed the essential dependencies: 50 | 51 | - [git](https://git-scm.com/) 52 | - [invoke](https://www.pyinvoke.org/) 53 | - [poetry](https://python-poetry.org/) 54 | - [python](https://www.python.org/) 3.6+ 55 | 56 | Then, execute: 57 | 58 | ```bash 59 | git clone https://github.com/Tecnativa/doodba-copier-template.git 60 | cd doodba-copier-template 61 | invoke develop 62 | ``` 63 | 64 | 🎉 Your development environment is ready! Start hacking. 65 | 66 | #### Know our Development Toolkit 67 | 68 | Once you did the steps above, it will be good for you to know that our basic building 69 | blocks here are: 70 | 71 | - [copier](https://github.com/pykong/copier) 72 | - [poetry](https://python-poetry.org/) 73 | - [pre-commit](https://pre-commit.com/) 74 | - [pytest](https://docs.pytest.org/) 75 | 76 | ### Open a Pull Request 77 | 78 | Follow 79 | [Github's instructions to open a pull request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request). 80 | 81 | After you've done that: 82 | 83 | 1. We will review it ASAP. 84 | 1. "ASAP" could be a long time; remember you don't pay us. 😉 85 | 1. If it fits the project, we will possibly ask you to change some things. 86 | 1. If it doesn't fit the project, we could reject it. Don't take it bad but maintaining 87 | stuff in the long term takes time... You can always use your own fork! 88 | -------------------------------------------------------------------------------- /_traefik2_hosts_labels.yml.jinja: -------------------------------------------------------------------------------- 1 | {%- import "_macros.jinja" as macros -%} 2 | 3 | {# Echo all path prefixes in a Traefik rule #} 4 | {%- macro path_prefix_rule(path_prefixes) -%} 5 | Path: 6 | {%- for path in path_prefixes %} 7 | {%- if path.endswith("/") %} 8 | {{- path }}{anything:.*} 9 | {%- else %} 10 | {{- path }},{{ path }}/{anything:.*} 11 | {%- endif %} 12 | {%- if not loop.last %},{% endif %} 13 | {%- endfor %} 14 | {%- endmacro %} 15 | 16 | {# Echo all domains of a group, in a Traefik rule #} 17 | {%- macro domains_rule(hosts, path_prefixes=()) -%} 18 | Host(`{{ hosts | join('`, `') }}`) 19 | {%- if path_prefixes -%} 20 | ;{{ path_prefix_rule(path_prefixes) }} 21 | {%- endif %} 22 | {%- endmacro %} 23 | 24 | {#- Basic labels for a single router #} 25 | {%- macro router(prefix, index0, rule, entrypoints=(), port=none) %} 26 | traefik.{{ prefix }}-{{ index0 }}.frontend.rule: {{ rule }} 27 | {%- if entrypoints %} 28 | traefik.{{ prefix }}-{{ index0 }}.frontend.entryPoints: 29 | {{ entrypoints|sort|join(",") }} 30 | {%- endif %} 31 | {%- if port %} 32 | traefik.{{ prefix }}-{{ index0 }}.port: {{ port }} 33 | {%- endif %} 34 | {%- endmacro %} 35 | 36 | {%- macro odoo(domain_groups_list, paths_without_crawlers, odoo_version) %} 37 | traefik.domain: {{ macros.first_main_domain(domain_groups_list)|tojson }} 38 | {%- call(domain_group) macros.domains_loop_grouped(domain_groups_list) %} 39 | 40 | {#- Route redirections #} 41 | {%- if domain_group.redirect_to %} 42 | traefik.alt-{{ domain_group.loop.index0 }}.frontend.redirect.regex: ^(.*)://([^/]+)/(.*)$$ 43 | traefik.alt-{{ domain_group.loop.index0 }}.frontend.redirect.replacement: $$1://{{ domain_group.redirect_to }}/$$3 44 | {{- 45 | router( 46 | prefix="alt", 47 | index0=domain_group.loop.index0, 48 | rule=domains_rule(domain_group.hosts, domain_group.path_prefixes), 49 | entrypoints=domain_group.entrypoints, 50 | ) 51 | }} 52 | {%- else %} 53 | 54 | {#- Forbidden crawler routers #} 55 | {%- if paths_without_crawlers and not domain_group.path_prefixes %} 56 | traefik.forbiddenCrawlers-{{ domain_group.loop.index0 }}.frontend.headers.customResponseHeaders: 57 | "X-Robots-Tag:noindex, nofollow" 58 | {{- 59 | router( 60 | prefix="forbiddenCrawlers", 61 | index0=domain_group.loop.index0, 62 | rule=domains_rule(domain_group.hosts, paths_without_crawlers), 63 | entrypoints=domain_group.entrypoints, 64 | ) 65 | }} 66 | {%- endif %} 67 | 68 | {#- Normal routers #} 69 | {%- if paths_without_crawlers != ["/"] or domain_group.path_prefixes %} 70 | {{- 71 | router( 72 | prefix="main", 73 | index0=domain_group.loop.index0, 74 | rule=domains_rule(domain_group.hosts, domain_group.path_prefixes), 75 | entrypoints=domain_group.entrypoints, 76 | ) 77 | }} 78 | {%- endif %} 79 | {%- if not domain_group.path_prefixes %} 80 | {%- set longpolling_route = "/longpolling/" if odoo_version < 16 else "/websocket" -%} 81 | {{- 82 | router( 83 | prefix="longpolling", 84 | index0=domain_group.loop.index0, 85 | rule=domains_rule(domain_group.hosts, [longpolling_route]), 86 | entrypoints=domain_group.entrypoints, 87 | port=8072, 88 | ) 89 | }} 90 | {%- endif %} 91 | {%- endif %} 92 | {%- endcall %} 93 | {%- endmacro %} 94 | -------------------------------------------------------------------------------- /_traefik1_labels.yml.jinja: -------------------------------------------------------------------------------- 1 | {%- import "_macros.jinja" as macros -%} 2 | {# 3 | Note: indentation of 6 spaces is important because that's the depth level 4 | of container labels in a docker-compose file. 5 | 6 | At some point, Traefik v1 will be unsupported and this file will disappear. 7 | #} 8 | 9 | {# Echo all path prefixes in a Traefik rule #} 10 | {%- macro path_prefix_rule(path_prefixes) -%} 11 | Path: 12 | {%- for path in path_prefixes %} 13 | {%- if path.endswith("/") %} 14 | {{- path }}{anything:.*} 15 | {%- else %} 16 | {{- path }},{{ path }}/{anything:.*} 17 | {%- endif %} 18 | {%- if not loop.last %},{% endif %} 19 | {%- endfor %} 20 | {%- endmacro %} 21 | 22 | {# Echo all domains of a group, in a Traefik rule #} 23 | {%- macro domains_rule(hosts, path_prefixes=()) -%} 24 | Host: 25 | {%- for host in hosts -%} 26 | {{ host }} 27 | {%- if not loop.last %}, {% endif %} 28 | {%- endfor -%} 29 | {%- if path_prefixes -%} 30 | ;{{ path_prefix_rule(path_prefixes) }} 31 | {%- endif %} 32 | {%- endmacro %} 33 | 34 | {#- Basic labels for a single router #} 35 | {%- macro router(prefix, index0, rule, entrypoints=(), port=none) %} 36 | traefik.{{ prefix }}-{{ index0 }}.frontend.rule: {{ rule }} 37 | {%- if entrypoints %} 38 | traefik.{{ prefix }}-{{ index0 }}.frontend.entryPoints: 39 | {{ entrypoints|sort|join(",") }} 40 | {%- endif %} 41 | {%- if port %} 42 | traefik.{{ prefix }}-{{ index0 }}.port: {{ port }} 43 | {%- endif %} 44 | {%- endmacro %} 45 | 46 | {%- macro odoo(domain_groups_list, paths_without_crawlers, odoo_version) %} 47 | traefik.domain: {{ macros.first_main_domain(domain_groups_list)|tojson }} 48 | {%- call(domain_group) macros.domains_loop_grouped(domain_groups_list) %} 49 | 50 | {#- Route redirections #} 51 | {%- if domain_group.redirect_to %} 52 | traefik.alt-{{ domain_group.loop.index0 }}.frontend.redirect.regex: ^(.*)://([^/]+)/(.*)$$ 53 | traefik.alt-{{ domain_group.loop.index0 }}.frontend.redirect.replacement: $$1://{{ domain_group.redirect_to }}/$$3 54 | {{- 55 | router( 56 | prefix="alt", 57 | index0=domain_group.loop.index0, 58 | rule=domains_rule(domain_group.hosts, domain_group.path_prefixes), 59 | entrypoints=domain_group.entrypoints, 60 | ) 61 | }} 62 | {%- else %} 63 | 64 | {#- Forbidden crawler routers #} 65 | {%- if paths_without_crawlers and not domain_group.path_prefixes %} 66 | traefik.forbiddenCrawlers-{{ domain_group.loop.index0 }}.frontend.headers.customResponseHeaders: 67 | "X-Robots-Tag:noindex, nofollow" 68 | {{- 69 | router( 70 | prefix="forbiddenCrawlers", 71 | index0=domain_group.loop.index0, 72 | rule=domains_rule(domain_group.hosts, paths_without_crawlers), 73 | entrypoints=domain_group.entrypoints, 74 | ) 75 | }} 76 | {%- endif %} 77 | 78 | {#- Normal routers #} 79 | {%- if paths_without_crawlers != ["/"] or domain_group.path_prefixes %} 80 | {{- 81 | router( 82 | prefix="main", 83 | index0=domain_group.loop.index0, 84 | rule=domains_rule(domain_group.hosts, domain_group.path_prefixes), 85 | entrypoints=domain_group.entrypoints, 86 | ) 87 | }} 88 | {%- endif %} 89 | {%- if not domain_group.path_prefixes %} 90 | {%- set longpolling_route = "/longpolling/" if odoo_version < 16 else "/websocket" -%} 91 | {{- 92 | router( 93 | prefix="longpolling", 94 | index0=domain_group.loop.index0, 95 | rule=domains_rule(domain_group.hosts, [longpolling_route]), 96 | entrypoints=domain_group.entrypoints, 97 | port=8072, 98 | ) 99 | }} 100 | {%- endif %} 101 | {%- endif %} 102 | {%- endcall %} 103 | {%- endmacro %} 104 | -------------------------------------------------------------------------------- /_traefik3_labels.yml.jinja: -------------------------------------------------------------------------------- 1 | {%- import "_macros.jinja" as macros -%} 2 | {# Echo all domains of a group, in a Traefik rule #} 3 | {%- macro domains_rule(hosts, path_prefixes=(), paths=()) -%} 4 | Host(`{{ hosts | join("`) || Host(`") }}`) 5 | {%- if path_prefixes or paths -%} 6 | {{" "}}&& ( 7 | {%- if path_prefixes -%} 8 | {{ path_prefix_rule(path_prefixes) }} 9 | {%- if paths %} || {% endif %} 10 | {%- endif %} 11 | {%- if paths -%} 12 | {{ path_rule(paths) }} 13 | {%- endif -%} 14 | ) 15 | {%- endif %} 16 | {%- endmacro %} 17 | 18 | {# Echo all path prefixes in a Traefik rule #} 19 | {%- macro path_prefix_rule(path_prefixes) -%} 20 | {%- for path in path_prefixes -%} 21 | {%- if path.endswith("/") -%} 22 | PathPrefix(`{{ path }}`) 23 | {%- else -%} 24 | PathPrefix(`{{ path }}`) || Path(`{{ path }}`) 25 | {%- endif -%} 26 | {%- if not loop.last %} || {% endif %} 27 | {%- endfor %} 28 | {%- endmacro %} 29 | 30 | {# Echo all paths in a Traefik rule #} 31 | {%- macro path_rule(paths) -%} 32 | {%- for path in paths -%} 33 | Path(`{{ path }}`) 34 | {%- if not loop.last %} || {% endif %} 35 | {%- endfor %} 36 | {%- endmacro %} 37 | 38 | {%- macro odoo(domain_groups_list, paths_without_crawlers, odoo_version, traefik_version) %} 39 | traefik.domain: {{ macros.first_main_domain(domain_groups_list)|tojson }} 40 | {%- call(domain_group) macros.domains_loop_grouped(domain_groups_list) %} 41 | 42 | {#- Route redirections #} 43 | {%- if domain_group.redirect_to %} 44 | traefik.alt-{{ domain_group.loop.index0 }}.frontend.redirect.regex: ^(.*)://([^/]+)/(.*)$$ 45 | traefik.alt-{{ domain_group.loop.index0 }}.frontend.redirect.replacement: $$1://{{ domain_group.redirect_to }}/$$3 46 | {{- 47 | router( 48 | prefix="alt", 49 | index0=domain_group.loop.index0, 50 | rule=domains_rule(domain_group.hosts, domain_group.path_prefixes), 51 | entrypoints=domain_group.entrypoints, 52 | ) 53 | }} 54 | {%- else %} 55 | 56 | {#- Forbidden crawler routers #} 57 | {%- if paths_without_crawlers and not domain_group.path_prefixes %} 58 | traefik.forbiddenCrawlers-{{ domain_group.loop.index0 }}.frontend.headers.customResponseHeaders: 59 | "X-Robots-Tag:noindex, nofollow" 60 | {{- 61 | router( 62 | prefix="forbiddenCrawlers", 63 | index0=domain_group.loop.index0, 64 | rule=domains_rule(domain_group.hosts, paths_without_crawlers), 65 | entrypoints=domain_group.entrypoints, 66 | ) 67 | }} 68 | {%- endif %} 69 | 70 | {#- Normal routers #} 71 | {%- if paths_without_crawlers != ["/"] or domain_group.path_prefixes %} 72 | {{- 73 | router( 74 | prefix="main", 75 | index0=domain_group.loop.index0, 76 | rule=domains_rule(domain_group.hosts, domain_group.path_prefixes), 77 | entrypoints=domain_group.entrypoints, 78 | ) 79 | }} 80 | {%- endif %} 81 | {%- if not domain_group.path_prefixes %} 82 | {%- set longpolling_route = "/longpolling/" if odoo_version < 16 else "/websocket" -%} 83 | {{- 84 | router( 85 | prefix="longpolling", 86 | index0=domain_group.loop.index0, 87 | rule=domains_rule(domain_group.hosts, [longpolling_route]), 88 | entrypoints=domain_group.entrypoints, 89 | port=8072, 90 | ) 91 | }} 92 | {%- endif %} 93 | {%- endif %} 94 | {%- endcall %} 95 | {%- endmacro %} 96 | {#- Basic labels for a single router #} 97 | {%- macro router(prefix, index0, rule, entrypoints=(), port=none) %} 98 | traefik.{{ prefix }}-{{ index0 }}.frontend.rule: {{ rule }} 99 | {%- if entrypoints %} 100 | traefik.{{ prefix }}-{{ index0 }}.frontend.entryPoints: 101 | {{ entrypoints|sort|join(",") }} 102 | {%- endif %} 103 | {%- if port %} 104 | traefik.{{ prefix }}-{{ index0 }}.port: {{ port }} 105 | {%- endif %} 106 | {%- endmacro %} 107 | -------------------------------------------------------------------------------- /common.yaml.jinja: -------------------------------------------------------------------------------- 1 | {% import "_macros.jinja" as macros -%} 2 | version: "2.4" 3 | 4 | services: 5 | odoo: 6 | {%- if odoo_oci_image %} 7 | image: {{ odoo_oci_image }}:{{ macros.version_minor(odoo_version) }} 8 | {%- endif %} 9 | build: 10 | context: ./odoo 11 | args: 12 | {%- if odoo_version >= 11 %} 13 | DB_VERSION: "{{ postgres_version or 'latest' }}" 14 | {%- endif %} 15 | ODOO_VERSION: "{{ macros.version_minor(odoo_version) }}" 16 | UID: "${UID:-1000}" 17 | GID: "${GID:-1000}" 18 | environment: 19 | EMAIL_FROM: "{{ smtp_default_from|default("", true) }}" 20 | PGDATABASE: &dbname {{ postgres_dbname }} 21 | PGUSER: &dbuser "{{ postgres_username }}" 22 | PROXY_MODE: "{% if odoo_proxy %}true{% else %}false{% endif %}" 23 | tty: true 24 | volumes: 25 | - filestore:/var/lib/odoo:z 26 | {%- if odoo_proxy == "traefik" %} 27 | labels: 28 | traefik.backend.buffering.retryExpression: IsNetworkError() && Attempts() < 5 29 | traefik.docker.network: "inverseproxy_shared" 30 | traefik.frontend.passHostHeader: "true" 31 | traefik.port: "8069" 32 | {%- endif %} 33 | 34 | {% if postgres_version -%} 35 | db: 36 | image: ghcr.io/tecnativa/postgres-autoconf:{{ postgres_version }}-alpine 37 | shm_size: 4gb 38 | environment: 39 | POSTGRES_DB: *dbname 40 | POSTGRES_USER: *dbuser 41 | CONF_EXTRA: | 42 | work_mem = 512MB 43 | volumes: 44 | - db:/var/lib/postgresql/data:z 45 | {%- endif %} 46 | 47 | smtpfake: 48 | image: docker.io/mailhog/mailhog 49 | {%- if smtp_relay_host %} 50 | 51 | smtpreal: 52 | image: ghcr.io/docker-mailserver/docker-mailserver:{{ smtp_relay_version }} 53 | hostname: "smtp.{{ smtp_canonical_default }}" 54 | stop_grace_period: 1m 55 | volumes: 56 | - mailconfig:/tmp/docker-mailserver:z 57 | - maildata:/var/mail:z 58 | - maillogs:/var/log/mail:z 59 | - maillogssupervisord:/var/log/supervisor:z 60 | - mailstate:/var/mail-state:z 61 | cap_add: 62 | - SYS_PTRACE 63 | environment: 64 | DEFAULT_RELAY_HOST: "[{{ smtp_relay_host }}]:{{ smtp_relay_port }}" 65 | {%- if smtp_relay_version|float < 11.0 %} 66 | DMS_DEBUG: 0 67 | {%- else %} 68 | LOG_LEVEL: info 69 | {%- endif %} 70 | ENABLE_SRS: 1 71 | ONE_DIR: 1 72 | PERMIT_DOCKER: connected-networks 73 | POSTFIX_INET_PROTOCOLS: ipv4 74 | POSTFIX_MESSAGE_SIZE_LIMIT: 52428800 # 50 MiB 75 | RELAY_HOST: "{{ smtp_relay_host }}" 76 | RELAY_PORT: "{{ smtp_relay_port }}" 77 | RELAY_USER: "{{ smtp_relay_user }}" 78 | SMTP_ONLY: 1 79 | SRS_DOMAINNAME: "{{ smtp_canonical_default }}" 80 | SRS_EXCLUDE_DOMAINS: "{{ 81 | ([smtp_canonical_default] + (smtp_canonical_domains or [])) 82 | |unique|sort|join(",") 83 | }}" 84 | SRS_SENDER_CLASSES: envelope_sender,header_sender 85 | {%- endif %} 86 | {%- if backup_dst and postgres_version|int >= 10 %} 87 | 88 | backup: 89 | image: ghcr.io/tecnativa/docker-duplicity-postgres{% if backup_dst.startswith(('boto3+s3://', 's3://', 's3+http://')) %}-s3{% endif %}:{{ backup_image_version }} 90 | hostname: backup.{% if domains_prod %}{{ macros.first_main_domain(domains_prod) }}{% else %}{{ project_name|replace('.','-') }}{% endif %} 91 | init: true 92 | environment: 93 | DB_VERSION: "{{ postgres_version or 'latest' }}" 94 | DBS_TO_INCLUDE: "{{ odoo_dbfilter | replace('$', '$$') | replace('%d', '.*') | replace('%h', '.*') }}" 95 | DST: "{{ backup_dst }}" 96 | {%- if smtp_relay_host %} 97 | EMAIL_FROM: "{{ backup_email_from }}" 98 | EMAIL_TO: "{{ backup_email_to }}" 99 | {%- endif %} 100 | {%- if backup_deletion %} 101 | JOB_500_WHAT: dup full $$SRC $$DST 102 | JOB_500_WHEN: weekly 103 | JOB_800_WHAT: dup --force remove-older-than 3M $$DST 104 | JOB_800_WHEN: weekly 105 | {%- endif %} 106 | PGDATABASE: *dbname 107 | PGUSER: *dbuser 108 | {%- if smtp_relay_host %} 109 | SMTP_HOST: smtplocal 110 | {%- endif %} 111 | TZ: "{{ backup_tz }}" 112 | volumes: 113 | - backup_cache:/root:z 114 | - filestore:/mnt/backup/src/odoo:z 115 | {%- endif %} 116 | -------------------------------------------------------------------------------- /.pylintrc.jinja: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | load-plugins=pylint_odoo 3 | score=n 4 | 5 | [ODOOLINT] 6 | {%- if odoo_version < 16 %} 7 | readme_template_url="https://github.com/OCA/maintainer-tools/blob/master/template/module/README.rst" 8 | manifest_required_authors={{ project_author }} 9 | manifest_required_keys=license 10 | manifest_deprecated_keys=description,active 11 | license_allowed=AGPL-3,GPL-2,GPL-2 or any later version,GPL-3,GPL-3 or any later version,LGPL-3,OPL-1,OEEL-1 12 | valid_odoo_versions={{ odoo_version }} 13 | {%- else %} 14 | readme-template-url="https://github.com/OCA/maintainer-tools/blob/master/template/module/README.rst" 15 | manifest-required-authors={{ project_author }} 16 | manifest-required-keys=license 17 | manifest-deprecated-keys=description,active 18 | license-allowed=AGPL-3,GPL-2,GPL-2 or any later version,GPL-3,GPL-3 or any later version,LGPL-3,OPL-1,OEEL-1 19 | valid-odoo-versions={{ odoo_version }} 20 | {%- endif %} 21 | 22 | [MESSAGES CONTROL] 23 | disable=all 24 | 25 | # This .pylintrc contains optional AND mandatory checks and is meant to be 26 | # loaded in an IDE to have it check everything, in the hope this will make 27 | # optional checks more visible to contributors who otherwise never look at a 28 | # green travis to see optional checks that failed. 29 | # .pylintrc-mandatory containing only mandatory checks is used the pre-commit 30 | # config as a blocking check. 31 | 32 | # messages that do not cause the lint step to fail 33 | enable=attribute-deprecated, 34 | consider-merging-classes-inherited, 35 | invalid-commit, 36 | license-allowed, 37 | manifest-author-string, 38 | manifest-deprecated-key, 39 | {%- if project_author %} 40 | manifest-required-author, 41 | {%- endif %} 42 | manifest-required-key, 43 | manifest-version-format, 44 | method-compute, 45 | method-inverse, 46 | method-required-super, 47 | method-search, 48 | missing-readme, 49 | odoo-addons-relative-import, 50 | print-used, 51 | sql-injection, 52 | translation-field, 53 | translation-required, 54 | use-vim-comment, 55 | {%- if odoo_version < 16 %} 56 | anomalous-backslash-in-string, 57 | api-one-deprecated, 58 | api-one-multi-together, 59 | assignment-from-none, 60 | class-camelcase, 61 | dangerous-default-value, 62 | dangerous-view-replace-wo-priority, 63 | duplicate-id-csv, 64 | duplicate-key, 65 | duplicate-xml-fields, 66 | duplicate-xml-record-id, 67 | eval-referenced, 68 | eval-used, 69 | incoherent-interpreter-exec-perm, 70 | missing-import-error, 71 | missing-manifest-dependency, 72 | openerp-exception-warning, 73 | pointless-statement, 74 | pointless-string-statement, 75 | redundant-keyword-arg, 76 | redundant-modulename-xml, 77 | reimported, 78 | relative-import, 79 | return-in-init, 80 | rst-syntax-error, 81 | too-few-format-args, 82 | unreachable, 83 | wrong-tabs-instead-of-spaces, 84 | xml-syntax-error, 85 | create-user-wo-reset-password, 86 | dangerous-filter-wo-user, 87 | deprecated-module, 88 | file-not-used, 89 | missing-newline-extrafiles, 90 | no-utf8-coding-comment, 91 | old-api7-method-defined, 92 | redefined-builtin, 93 | too-complex, 94 | unnecessary-utf8-coding-comment 95 | {%- else %} 96 | attribute-string-redundant, 97 | bad-builtin-groupby, 98 | context-overridden, 99 | development-status-allowed, 100 | except-pass, 101 | external-request-timeout, 102 | manifest-data-duplicated, 103 | manifest-maintainers-list, 104 | missing-return, 105 | no-raise-unlink, 106 | no-wizard-in-models, 107 | no-write-in-compute, 108 | odoo-exception-warning, 109 | renamed-field-parameter, 110 | resource-not-exist, 111 | test-folder-imported, 112 | translation-contains-variable, 113 | translation-format-interpolation, 114 | translation-format-truncated, 115 | translation-fstring-interpolation, 116 | translation-not-lazy, 117 | translation-positional-used, 118 | translation-too-few-args, 119 | translation-too-many-args, 120 | translation-unsupported-format, 121 | website-manifest-key-not-valid-uri 122 | {%- endif %} 123 | 124 | [REPORTS] 125 | msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg} 126 | output-format=colorized 127 | reports=no 128 | -------------------------------------------------------------------------------- /devel.yaml.jinja: -------------------------------------------------------------------------------- 1 | {%- import "_macros.jinja" as macros -%} 2 | {% set whitelisted_hosts = ( 3 | "cdnjs.cloudflare.com", 4 | "fonts.googleapis.com", 5 | "fonts.gstatic.com", 6 | "www.google.com", 7 | "www.googleapis.com", 8 | "www.gravatar.com", 9 | ) -%} 10 | version: "2.4" 11 | 12 | services: 13 | odoo_proxy: 14 | image: ghcr.io/tecnativa/docker-whitelist:latest 15 | depends_on: 16 | - odoo 17 | networks: &public 18 | default: 19 | public: 20 | ports: 21 | - "127.0.0.1:${PORT_PREFIX:-{{ macros.version_major(odoo_version) }}}899:6899" 22 | - "127.0.0.1:${PORT_PREFIX:-{{ macros.version_major(odoo_version) }}}069:8069" 23 | - "127.0.0.1:${PORT_PREFIX:-{{ macros.version_major(odoo_version) }}}072:8072" 24 | environment: 25 | PORT: "6899 8069 8072" 26 | TARGET: odoo 27 | 28 | odoo: 29 | extends: 30 | file: common.yaml 31 | service: odoo 32 | build: 33 | args: 34 | # To aggregate in development, use `setup-devel.yaml` 35 | AGGREGATE: "false" 36 | # Export these variables to own files created by odoo in your filesystem 37 | UID: "${UID:-1000}" 38 | GID: "${GID:-1000}" 39 | # No need for this in development 40 | PIP_INSTALL_ODOO: "false" 41 | CLEAN: "false" 42 | COMPILE: "false" 43 | environment: 44 | DOODBA_ENVIRONMENT: "${DOODBA_ENVIRONMENT-devel}" 45 | INITIAL_LANG: "{{ odoo_initial_lang }}" 46 | LIST_DB: "true" 47 | DEBUGPY_ENABLE: "${DOODBA_DEBUGPY_ENABLE:-0}" 48 | PGDATABASE: &dbname devel 49 | PYTHONDONTWRITEBYTECODE: 1 50 | PYTHONOPTIMIZE: "" 51 | PYTHONPATH: /opt/odoo/custom/src/odoo 52 | SMTP_PORT: "1025" 53 | WDB_WEB_PORT: "${PORT_PREFIX:-{{ macros.version_major(odoo_version) }}}984" 54 | # To avoid installing demo data export DOODBA_WITHOUT_DEMO=all 55 | WITHOUT_DEMO: "${DOODBA_WITHOUT_DEMO-false}" 56 | volumes: 57 | - ./odoo/custom:/opt/odoo/custom:ro,z 58 | - ./odoo/auto:/opt/odoo/auto:rw,z 59 | depends_on: 60 | - db 61 | {% for host in whitelisted_hosts -%} 62 | - proxy_{{ host|replace(".", "_") }} 63 | {% endfor -%} 64 | - smtp 65 | - wdb 66 | command: 67 | - odoo 68 | - --limit-memory-soft=0 69 | {%- if odoo_version >= 10 %} 70 | - --limit-time-real-cron=9999999 71 | {%- endif %} 72 | - --limit-time-real=9999999 73 | - --workers=0 74 | {%- if odoo_version == 9 %} 75 | - --dev 76 | {%- elif odoo_version >= 10 and odoo_version < 19 %} 77 | - --dev=reload,qweb,werkzeug,xml 78 | {%- else %} 79 | - --dev=reload,qweb,werkzeug,xml,access 80 | {%- endif %} 81 | 82 | {% if postgres_version -%} 83 | db: 84 | extends: 85 | file: common.yaml 86 | service: db 87 | environment: 88 | POSTGRES_DB: *dbname 89 | POSTGRES_PASSWORD: odoopassword 90 | {%- endif %} 91 | 92 | pgweb: 93 | image: docker.io/sosedoff/pgweb 94 | networks: *public 95 | ports: 96 | - "127.0.0.1:${PORT_PREFIX:-{{ macros.version_major(odoo_version) }}}081:8081" 97 | environment: 98 | DATABASE_URL: postgres://{{ postgres_username }}:odoopassword@db:5432/devel?sslmode=disable 99 | depends_on: 100 | - db 101 | 102 | smtp: 103 | extends: 104 | file: common.yaml 105 | service: smtpfake 106 | networks: *public 107 | ports: 108 | - "127.0.0.1:${PORT_PREFIX:-{{ macros.version_major(odoo_version) }}}025:8025" 109 | 110 | wdb: 111 | image: docker.io/kozea/wdb 112 | networks: *public 113 | ports: 114 | - "127.0.0.1:${PORT_PREFIX:-{{ macros.version_major(odoo_version) }}}984:1984" 115 | # HACK https://github.com/Kozea/wdb/issues/136 116 | init: true 117 | 118 | # Whitelist outgoing traffic for tests, reports, etc. 119 | {%- for host in whitelisted_hosts %} 120 | proxy_{{ host|replace(".", "_") }}: 121 | image: ghcr.io/tecnativa/docker-whitelist:latest 122 | networks: 123 | default: 124 | aliases: 125 | - {{ host }} 126 | public: 127 | environment: 128 | TARGET: {{ host }} 129 | PRE_RESOLVE: 1 130 | {% endfor %} 131 | networks: 132 | default: 133 | internal: ${DOODBA_NETWORK_INTERNAL-true} 134 | public: 135 | 136 | volumes: 137 | filestore: 138 | db: 139 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | inputs: 12 | pytest_addopts: 13 | description: 14 | Extra options for pytest; use -vv for full details; see 15 | https://docs.pytest.org/en/latest/example/simple.html#how-to-change-command-line-options-defaults 16 | required: false 17 | 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | LANG: "en_US.utf-8" 21 | LC_ALL: "en_US.utf-8" 22 | PIP_CACHE_DIR: ${{ github.workspace }}/.cache.~/pip 23 | PIPX_HOME: ${{ github.workspace }}/.cache.~/pipx 24 | POETRY_CACHE_DIR: ${{ github.workspace }}/.cache.~/pypoetry 25 | POETRY_VIRTUALENVS_IN_PROJECT: "true" 26 | PRE_COMMIT_HOME: ${{ github.workspace }}/.cache.~/pre-commit 27 | PYTEST_ADDOPTS: ${{ github.event.inputs.pytest_addopts }} 28 | PYTHONIOENCODING: "UTF-8" 29 | 30 | jobs: 31 | test: 32 | runs-on: ubuntu-latest 33 | strategy: 34 | matrix: 35 | odoo-version: ["19.0"] 36 | python-version: ["3.12"] 37 | traefik-version: [3] 38 | include: 39 | - odoo-version: "18.0" 40 | python-version: "3.10" 41 | traefik-version: 2 42 | - odoo-version: "18.0" 43 | python-version: "3.11" 44 | traefik-version: 3 45 | - odoo-version: "17.0" 46 | python-version: "3.10" 47 | traefik-version: 2 48 | - odoo-version: "17.0" 49 | python-version: "3.10" 50 | traefik-version: 3 51 | - odoo-version: "16.0" 52 | python-version: "3.10" 53 | traefik-version: 2 54 | - odoo-version: "16.0" 55 | python-version: "3.10" 56 | traefik-version: 3 57 | - odoo-version: "15.0" 58 | python-version: "3.9" 59 | traefik-version: 2 60 | - odoo-version: "15.0" 61 | python-version: "3.9" 62 | traefik-version: 3 63 | - odoo-version: "14.0" 64 | python-version: "3.9" 65 | traefik-version: 3 66 | - odoo-version: "13.0" 67 | python-version: "3.9" 68 | traefik-version: 3 69 | 70 | steps: 71 | # Shared steps 72 | - uses: actions/checkout@v5 73 | with: 74 | fetch-depth: 0 # Fetch all history for all tags and branches 75 | - name: Install python 76 | uses: actions/setup-python@v6 77 | with: 78 | python-version: ${{ matrix.python-version }} 79 | - name: Install Compose 80 | uses: ndeloof/install-compose-action@c036dce35dc2a23597c03aa05f32347b2cfa0437 81 | with: 82 | version: v2.26.0 83 | legacy: false # force V2 84 | - name: Generate cache key CACHE 85 | run: 86 | echo "CACHE=${{ secrets.CACHE_DATE }} ${{ runner.os }} $(python -VV | 87 | sha256sum | cut -d' ' -f1) ${{ hashFiles('pyproject.toml') }} ${{ 88 | hashFiles('poetry.lock') }} ${{ hashFiles('.pre-commit-config.yaml') }}" >> 89 | $GITHUB_ENV 90 | - uses: actions/cache@v4 91 | with: 92 | path: | 93 | .cache.~ 94 | .venv 95 | ~/.local/bin 96 | key: venv ${{ env.CACHE }} 97 | - run: pip install poetry 98 | - name: Patch $PATH 99 | run: echo "$HOME/.local/bin" >> $GITHUB_PATH 100 | - run: poetry install --no-root 101 | # Precreate shared networks to avoid race conditions 102 | - run: docker network create inverseproxy_shared 103 | - run: docker network create globalwhitelist_shared 104 | # Let tests issue git commits 105 | - run: git config --global user.name CI 106 | - run: git config --global user.email CI@GITHUB 107 | # Run all tests, which includes linters 108 | # Non concurrent first (in parallel) 109 | - run: poetry run invoke test 110 | env: 111 | SELECTED_ODOO_VERSIONS: ${{ matrix.odoo-version }} 112 | TRAEFIK_VERSION: ${{ matrix.traefik-version }} 113 | # Concurrent tests (isolated) 114 | - run: poetry run invoke test --sequential 115 | env: 116 | SELECTED_ODOO_VERSIONS: ${{ matrix.odoo-version }} 117 | TRAEFIK_VERSION: ${{ matrix.traefik-version }} 118 | -------------------------------------------------------------------------------- /tests/test_migrations.py: -------------------------------------------------------------------------------- 1 | from glob import glob 2 | from pathlib import Path 3 | 4 | from copier import run_update 5 | from plumbum import local 6 | from plumbum.cmd import git, invoke 7 | 8 | from .conftest import DBVER_PER_ODOO 9 | 10 | OLDEST_TEMPLATE_VERSION = "v0.1.0" 11 | 12 | # Notes: 13 | # - Template versions before 5.1.4 are not compatible with latest pyhton versions (pre-commit regex issue). 14 | # - Template versions before 3.0.0 are not compatible with latest copier versions ('postgres_version' question has invalid choice type). 15 | # - copier-answers before 5.2.0 are not compatible with latest copier versions (null answers are not supported for some question types). 16 | 17 | 18 | def test_transtion_to_copier( 19 | tmp_path: Path, cloned_template: Path, supported_odoo_version: float 20 | ): 21 | """Test transition from old git-clone-based workflow to new copier-based.""" 22 | tag = "v999999.99.99" 23 | with local.cwd(cloned_template): 24 | git("tag", "--delete", "test") 25 | git("tag", "--force", tag) 26 | # Emulate user cloning scaffolding using the old workflow 27 | git("clone", "https://github.com/Tecnativa/doodba-scaffolding", tmp_path) 28 | with local.cwd(tmp_path): 29 | # Emulate user modifying some basic variables and committing 30 | env_file = tmp_path / ".env" 31 | env_contents = env_file.read_text() 32 | env_contents = env_contents.replace( 33 | "ODOO_MAJOR=11", f"ODOO_MAJOR={int(supported_odoo_version)}" 34 | ) 35 | env_contents = env_contents.replace( 36 | "ODOO_MINOR=11.0", f"ODOO_MINOR={supported_odoo_version:.1f}" 37 | ) 38 | env_contents = env_contents.replace( 39 | "ODOO_IMAGE=docker.io/myuser/myproject-odoo", 40 | "ODOO_IMAGE=registry.example.com/custom-team/custom-project-odoo", 41 | ) 42 | env_file.write_text(env_contents) 43 | addons_file = tmp_path / "odoo" / "custom" / "src" / "addons.yaml" 44 | addons_file.write_text('server-tools: ["*"]') 45 | assert 'server-tools: ["*"]' in addons_file.read_text() 46 | answers_file = tmp_path / ".copier-answers.yml" 47 | answers_file_contents = answers_file.read_text() 48 | answers_file_contents = answers_file_contents.replace( 49 | "_src_path: https://github.com/Tecnativa/doodba-copier-template.git", 50 | f"_src_path: {cloned_template}", 51 | ) 52 | answers_file_contents = answers_file_contents.replace( 53 | "_commit: v0.0.0-0-0", 54 | f"_commit: {OLDEST_TEMPLATE_VERSION}", 55 | ) 56 | 57 | answers_file.write_text(answers_file_contents) 58 | assert f"_src_path: {cloned_template}" in answers_file.read_text() 59 | dep_files = glob(str(tmp_path / "odoo" / "custom" / "dependencies" / "*.txt")) 60 | assert len(dep_files) == 5 61 | for dep_file in map(Path, dep_files): 62 | with dep_file.open("a") as dep_fd: 63 | dep_fd.write("\n# a comment") 64 | git("add", ".") 65 | git("commit", "-m", "update") 66 | # Emulate user upgrading to copier, passing the right variables 67 | dbver = DBVER_PER_ODOO[supported_odoo_version]["latest"] 68 | run_update( 69 | dst_path=str(tmp_path), 70 | data={ 71 | "odoo_version": supported_odoo_version, 72 | "postgres_version": dbver, 73 | }, 74 | user_defaults={ 75 | "gitlab_host": "", 76 | "domain_prod": "", 77 | "domain_prod_alternatives": "", 78 | "domain_test": "", 79 | "odoo_oci_image": "registry.example.com/custom-team/custom-project-odoo", 80 | "smtp_default_from": "", 81 | "smtp_relay_host": "", 82 | "smtp_relay_user": "", 83 | "smtp_canonical_default": "", 84 | "smtp_canonical_domains": "", 85 | "backup_dst": "", 86 | "backup_email_from": "", 87 | "backup_email_to": "", 88 | "backup_deletion": False, 89 | "backup_aws_access_key_id": "", 90 | "backup_aws_secret_access_key": "", 91 | }, 92 | vcs_ref=tag, 93 | defaults=True, 94 | overwrite=True, 95 | unsafe=True, 96 | ) 97 | # Check migrations ran fine 98 | # env file was removed in 'update_domains_structure' migration script 99 | assert not env_file.exists() 100 | assert not (tmp_path / ".travis.yml").exists() 101 | assert not (tmp_path / ".vscode" / "doodba").exists() 102 | assert not (tmp_path / ".vscode" / "doodbasetup.py").exists() 103 | assert not ( 104 | tmp_path / "odoo" / "custom" / "src" / "private" / ".empty" 105 | ).exists() 106 | # Ensure migrations are resilient to subproject changes 107 | invoke( 108 | "--search-root", 109 | cloned_template, 110 | "--collection", 111 | "migrations", 112 | "from-doodba-scaffolding-to-copier", 113 | ) 114 | -------------------------------------------------------------------------------- /prod.yaml.jinja: -------------------------------------------------------------------------------- 1 | {%- import "_macros.jinja" as macros -%} 2 | {%- import "_traefik1_labels.yml.jinja" as traefik1_labels -%} 3 | {%- import "_traefik1_paths_labels.yml.jinja" as traefik1_path_labels -%} 4 | {%- import "_traefik2_labels.yml.jinja" as traefik2_labels -%} 5 | {%- import "_traefik2_hosts_labels.yml.jinja" as traefik2_hosts_labels -%} 6 | {%- import "_traefik3_labels.yml.jinja" as traefik3_labels -%} 7 | {%- import "_traefik3_paths_labels.yml.jinja" as traefik3_labels_2 -%} 8 | {%- set _key = traefik2_labels.key(project_name, odoo_version, "prod") -%} 9 | version: "2.4" 10 | 11 | services: 12 | odoo: 13 | extends: 14 | file: common.yaml 15 | service: odoo 16 | restart: unless-stopped 17 | {%- if domains_prod %} 18 | hostname: {{ macros.first_main_domain(domains_prod)|tojson }} 19 | {%- endif %} 20 | env_file: 21 | - .docker/odoo.env 22 | - .docker/db-access.env 23 | environment: 24 | DB_FILTER: "{{ odoo_dbfilter | replace('$', '$$') }}" 25 | DOODBA_ENVIRONMENT: "${DOODBA_ENVIRONMENT-prod}" 26 | INITIAL_LANG: "{{ odoo_initial_lang }}" 27 | LIST_DB: "{{ odoo_listdb | tojson }}" 28 | {%- if odoo_version >= 16.0 %} 29 | ODOO_BUS_PUBLIC_SAMESITE_WS: 1 30 | {%- endif %} 31 | {%- if smtp_relay_host %} 32 | SMTP_SERVER: smtplocal 33 | {%- endif %} 34 | PGHOST: {{ _key }}-db 35 | depends_on: 36 | - db 37 | {%- if smtp_relay_host %} 38 | - smtp 39 | {%- endif %} 40 | networks: 41 | default: 42 | {%- if odoo_proxy == "traefik" and domains_prod %} 43 | inverseproxy_shared: 44 | {%- endif %} 45 | labels: 46 | doodba.domain.main: {{ macros.first_main_domain(domains_prod)|tojson }} 47 | {%- if odoo_proxy == "traefik" and domains_prod %} 48 | traefik.enable: "true" 49 | {%- if traefik_version == 3 -%} 50 | {{- traefik3_labels.odoo(domains_prod, paths_without_crawlers, odoo_version, traefik_version)}} 51 | {%- elif traefik_version == 2 -%} 52 | {{- traefik2_hosts_labels.odoo(domains_prod, paths_without_crawlers, odoo_version)}} 53 | {%- else -%} 54 | {{- traefik1_labels.odoo(domains_prod, paths_without_crawlers, odoo_version)}} 55 | {%- endif -%} 56 | {{- traefik2_labels.common_middlewares(_key, cidr_whitelist) }} 57 | {%- if traefik_version == 3 -%} 58 | {{- traefik3_labels_2.odoo( 59 | domains_prod, 60 | cidr_whitelist, 61 | _key, 62 | odoo_version, 63 | paths_without_crawlers, 64 | paths_with_crawlers, 65 | project_name, 66 | ) }} 67 | {%- elif traefik_version == 2 -%} 68 | {{- traefik2_labels.odoo( 69 | domains_prod, 70 | cidr_whitelist, 71 | _key, 72 | odoo_version, 73 | paths_without_crawlers, 74 | paths_with_crawlers, 75 | project_name, 76 | ) }} 77 | {%- else -%} 78 | {{- traefik1_path_labels.odoo( 79 | domains_prod, 80 | cidr_whitelist, 81 | _key, 82 | odoo_version, 83 | paths_without_crawlers, 84 | paths_with_crawlers, 85 | project_name, 86 | ) }} 87 | {%- endif %} 88 | {%- endif %} 89 | 90 | {% if postgres_version -%} 91 | db: 92 | extends: 93 | file: common.yaml 94 | service: db 95 | environment: 96 | DB_HOST: {{ _key }}-db 97 | env_file: 98 | - .docker/db-creation.env 99 | restart: unless-stopped 100 | networks: 101 | default: 102 | aliases: 103 | - "{{ _key }}-db" 104 | {%- if postgres_exposed %} 105 | {%- if traefik_version >= 3 %} 106 | inverseproxy_shared: 107 | labels: 108 | traefik.enable: "true" 109 | traefik.docker.network: "inverseproxy_shared" 110 | {{- traefik3_labels_2.database( 111 | domains_prod, 112 | postgres_cidr_whitelist, 113 | _key, 114 | postgres_exposed_port, 115 | project_name, 116 | ) }} 117 | {%- else %} 118 | ports: 119 | - "{{ postgres_exposed_port }}:5432" 120 | {%- endif %} 121 | {%- endif %} 122 | {%- endif %} 123 | 124 | {%- if smtp_relay_host %} 125 | 126 | smtp: 127 | extends: 128 | file: common.yaml 129 | service: smtpreal 130 | env_file: 131 | - .docker/smtp.env 132 | networks: 133 | default: 134 | aliases: 135 | - smtplocal 136 | restart: unless-stopped 137 | {%- endif %} 138 | {%- if backup_dst and postgres_version|int >= 10 %} 139 | 140 | backup: 141 | extends: 142 | file: common.yaml 143 | service: backup 144 | env_file: 145 | - .docker/backup.env 146 | - .docker/db-access.env 147 | restart: unless-stopped 148 | depends_on: 149 | - db 150 | {%- if smtp_relay_host %} 151 | - smtp 152 | {%- endif %} 153 | {%- endif %} 154 | 155 | networks: 156 | default: 157 | driver_opts: 158 | encrypted: 1 159 | {%- if odoo_proxy == "traefik" %} 160 | 161 | inverseproxy_shared: 162 | external: true 163 | {%- endif %} 164 | 165 | volumes: 166 | {%- if backup_dst %} 167 | backup_cache: 168 | {%- endif %} 169 | filestore: 170 | db: 171 | {%- if smtp_relay_host %} 172 | mailconfig: 173 | maildata: 174 | maillogs: 175 | maillogssupervisord: 176 | mailstate: 177 | {%- endif %} 178 | -------------------------------------------------------------------------------- /test.yaml.jinja: -------------------------------------------------------------------------------- 1 | {%- import "_macros.jinja" as macros -%} 2 | {%- import "_traefik1_labels.yml.jinja" as traefik1_labels -%} 3 | {%- import "_traefik2_labels.yml.jinja" as traefik2_labels -%} 4 | {%- import "_traefik3_labels.yml.jinja" as traefik3_labels -%} 5 | {%- set _key = traefik2_labels.key(project_name, odoo_version, "test") -%} 6 | version: "2.4" 7 | 8 | services: 9 | odoo: 10 | extends: 11 | file: common.yaml 12 | service: odoo 13 | env_file: 14 | - .docker/odoo.env 15 | - .docker/db-access.env 16 | environment: 17 | DOODBA_ENVIRONMENT: "${DOODBA_ENVIRONMENT-test}" 18 | LIST_DB: "{{ odoo_listdb_staging | tojson }}" 19 | {%- if odoo_version >= 16.0 %} 20 | ODOO_BUS_PUBLIC_SAMESITE_WS: 1 21 | {%- endif %} 22 | # To install demo data export DOODBA_WITHOUT_DEMO=false 23 | WITHOUT_DEMO: "${DOODBA_WITHOUT_DEMO-all}" 24 | SMTP_PORT: "1025" 25 | SMTP_SERVER: smtplocal 26 | # Just in case you use queue_job 27 | ODOO_QUEUE_JOB_CHANNELS: "root:1" 28 | PGHOST: {{ _key }}-db 29 | restart: unless-stopped 30 | {%- if domains_test %} 31 | hostname: {{ macros.first_main_domain(domains_test)|tojson }} 32 | {%- endif %} 33 | depends_on: 34 | - db 35 | - smtp 36 | networks: 37 | default: 38 | globalwhitelist_shared: 39 | {%- if odoo_proxy == "traefik" and domains_test %} 40 | inverseproxy_shared: 41 | {%- endif %} 42 | labels: 43 | doodba.domain.main: {{ macros.first_main_domain(domains_test)|tojson }} 44 | {%- if odoo_proxy == "traefik" and domains_test %} 45 | traefik.enable: "true" 46 | {{- traefik1_labels.odoo(domains_test, ["/"], odoo_version) }} 47 | {{- traefik2_labels.odoo( 48 | domains_test, 49 | cidr_whitelist, 50 | _key, 51 | odoo_version, 52 | ["/"], 53 | [], 54 | project_name, 55 | ) }} 56 | {%- endif %} 57 | command: 58 | - odoo 59 | - --workers=3 60 | - --max-cron-threads=1 61 | 62 | {% if postgres_version -%} 63 | db: 64 | extends: 65 | file: common.yaml 66 | service: db 67 | environment: 68 | DB_HOST: {{ _key }}-db 69 | env_file: 70 | - .docker/db-creation.env 71 | networks: 72 | default: 73 | aliases: 74 | - "{{ _key }}-db" 75 | restart: unless-stopped 76 | {%- endif %} 77 | 78 | smtp: 79 | extends: 80 | file: common.yaml 81 | service: smtpfake 82 | restart: unless-stopped 83 | networks: 84 | default: 85 | aliases: 86 | - smtplocal 87 | {%- if odoo_proxy == "traefik" and domains_test %} 88 | inverseproxy_shared: 89 | {%- endif %} 90 | labels: 91 | doodba.domain.main: {{ macros.first_main_domain(domains_test)|tojson }} 92 | {%- if odoo_proxy == "traefik" and domains_test %} 93 | traefik.docker.network: "inverseproxy_shared" 94 | traefik.enable: "true" 95 | {#- Traefik v1 labels #} 96 | traefik.frontend.passHostHeader: "true" 97 | {%- call(domain_group) macros.domains_loop_grouped(domains_test|rejectattr("redirect_to")|rejectattr("path_prefixes")) %} 98 | {{- 99 | traefik1_labels.router( 100 | prefix="mailhog", 101 | index0=domain_group.loop.index0, 102 | rule="%s;PathPrefixStrip:/smtpfake/"|format( 103 | traefik1_labels.domains_rule(domain_group.hosts), 104 | ), 105 | entrypoints=domain_group.entrypoints, 106 | port=8025, 107 | ) 108 | }} 109 | {%- endcall %} 110 | {#- Traefik v2 labels #} 111 | # Mailhog service 112 | traefik.http.middlewares.{{ _key }}-mailhog-stripprefix.stripPrefix.prefixes: /smtpfake/ 113 | traefik.http.services.{{ _key }}-mailhog.loadbalancer.server.port: 8025 114 | {{- traefik2_labels.common_middlewares(_key, cidr_whitelist) }} 115 | {%- call(domain_group) macros.domains_loop_grouped(domains_test|rejectattr("redirect_to")|rejectattr("path_prefixes")) %} 116 | {#- Remember basic middlewares for this domain group #} 117 | {%- set _ns = namespace(basic_middlewares=[]) -%} 118 | {%- if cidr_whitelist %} 119 | {%- set _ns.basic_middlewares = _ns.basic_middlewares + ["whitelist"] %} 120 | {%- endif %} 121 | {%- if domain_group.cert_resolver %} 122 | {%- set _ns.basic_middlewares = _ns.basic_middlewares + ["addSTS", "forceSecure"] %} 123 | {%- endif %} 124 | {{- 125 | traefik2_labels.router( 126 | domain_group=domain_group, 127 | key=_key, 128 | suffix="mailhog", 129 | rule="%s && PathPrefix(`/smtpfake/`)"|format(traefik2_labels.domains_rule(domain_group)), 130 | service="mailhog", 131 | middlewares=_ns.basic_middlewares + [ 132 | "buffering", 133 | "compress", 134 | "forbid-crawlers", 135 | "mailhog-stripprefix", 136 | ], 137 | ) 138 | }} 139 | {%- endcall %} 140 | {%- endif %} 141 | volumes: 142 | - "smtpconf:/etc/mailhog:ro,z" 143 | entrypoint: [sh, -c] 144 | command: 145 | - test -r /etc/mailhog/auth && export MH_AUTH_FILE=/etc/mailhog/auth; exec MailHog 146 | 147 | networks: 148 | default: 149 | internal: ${DOODBA_NETWORK_INTERNAL-true} 150 | driver_opts: 151 | encrypted: 1 152 | 153 | globalwhitelist_shared: 154 | external: true 155 | {%- if odoo_proxy == "traefik" %} 156 | 157 | inverseproxy_shared: 158 | external: true 159 | {%- endif %} 160 | 161 | volumes: 162 | filestore: 163 | db: 164 | smtpconf: 165 | -------------------------------------------------------------------------------- /tests/test_settings_effect.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from copier import run_copy 5 | from plumbum import local 6 | from python_on_whales import DockerClient 7 | 8 | from .conftest import DBVER_PER_ODOO 9 | 10 | 11 | @pytest.mark.parametrize("backup_deletion", (False, True)) 12 | @pytest.mark.parametrize( 13 | "backup_dst", 14 | ("", "s3://example", "s3+http://example", "boto3+s3://example", "sftp://example"), 15 | ) 16 | @pytest.mark.parametrize("backup_image_version", ("latest")) 17 | @pytest.mark.parametrize("smtp_relay_host", ("", "example")) 18 | def _test_backup_config( 19 | backup_deletion: bool, 20 | backup_dst: str, 21 | backup_image_version: str, 22 | cloned_template: Path, 23 | smtp_relay_host: str, 24 | supported_odoo_version: float, 25 | tmp_path: Path, 26 | ): 27 | """Test that backup deletion setting is respected.""" 28 | data = { 29 | "backup_deletion": backup_deletion, 30 | "backup_dst": backup_dst, 31 | "backup_image_version": backup_image_version, 32 | "odoo_version": supported_odoo_version, 33 | "postgres_version": DBVER_PER_ODOO[supported_odoo_version]["latest"], 34 | "smtp_relay_host": smtp_relay_host, 35 | } 36 | # Remove parameter if False, to test this is the properly default value 37 | if not backup_deletion: 38 | del data["backup_deletion"] 39 | with local.cwd(tmp_path): 40 | run_copy( 41 | src_path=str(cloned_template), 42 | dst_path=".", 43 | data=data, 44 | vcs_ref="test", 45 | defaults=True, 46 | overwrite=True, 47 | unsafe=True, 48 | ) 49 | dc_prod = DockerClient(compose_files=["prod.yaml"]) 50 | prod_config = dc_prod.compose.config() 51 | # Check backup service existence 52 | if not backup_dst: 53 | assert "backup" not in prod_config.services 54 | return 55 | # Check selected duplicity image 56 | if "s3" in backup_dst: 57 | assert prod_config.services[ 58 | "backup" 59 | ].image == "ghcr.io/tecnativa/docker-duplicity-postgres-s3:{}".format( 60 | backup_image_version 61 | ) 62 | else: 63 | assert ( 64 | prod_config.services["backup"].image 65 | == f"ghcr.io/tecnativa/docker-duplicity-postgres:{backup_image_version}" 66 | ) 67 | # Check SMTP configuration 68 | if smtp_relay_host: 69 | assert "smtp" in prod_config.services 70 | assert prod_config.services["backup"].environment["SMTP_HOST"] == "smtplocal" 71 | assert "EMAIL_FROM" in prod_config.services["backup"].environment 72 | assert "EMAIL_TO" in prod_config.services["backup"].environment 73 | else: 74 | assert "smtp" not in prod_config.services 75 | assert "SMTP_HOST" not in prod_config.services["backup"].environment 76 | assert "EMAIL_FROM" not in prod_config.services["backup"].environment 77 | assert "EMAIL_TO" not in prod_config.services["backup"].environment 78 | # Check backup deletion 79 | if backup_deletion: 80 | assert ( 81 | prod_config.services["backup"].environment["JOB_800_WHAT"] 82 | == "dup --force remove-older-than 3M $$DST" 83 | ) 84 | assert prod_config.services["backup"].environment["JOB_800_WHEN"] == "weekly" 85 | else: 86 | assert "JOB_800_WHAT" not in prod_config.services["backup"].environment 87 | assert "JOB_800_WHEN" not in prod_config.services["backup"].environment 88 | 89 | 90 | def test_dbfilter_default( 91 | cloned_template: Path, supported_odoo_version: float, tmp_path: Path 92 | ): 93 | """Default DB filter inherits database name and is applied to prod only.""" 94 | with local.cwd(tmp_path): 95 | run_copy( 96 | src_path=str(cloned_template), 97 | dst_path=".", 98 | data={ 99 | "odoo_version": supported_odoo_version, 100 | "postgres_version": DBVER_PER_ODOO[supported_odoo_version]["latest"], 101 | "backup_dst": "file:///here", 102 | }, 103 | vcs_ref="test", 104 | defaults=True, 105 | overwrite=True, 106 | unsafe=True, 107 | ) 108 | devel, test, prod = map( 109 | lambda env: DockerClient(compose_files=[f"{env}.yaml"]).compose.config(), 110 | ("devel", "test", "prod"), 111 | ) 112 | assert "DB_FILTER" not in devel.services["odoo"].environment 113 | assert "DB_FILTER" not in test.services["odoo"].environment 114 | assert prod.services["odoo"].environment["DB_FILTER"] == "^prod" 115 | assert prod.services["backup"].environment["DBS_TO_INCLUDE"] == "^prod" 116 | 117 | 118 | def test_dbfilter_custom_odoo_extensions( 119 | cloned_template: Path, supported_odoo_version: float, tmp_path: Path 120 | ): 121 | """Fixes custom Odoo regexp extensions in dbfilter for the backup service.""" 122 | with local.cwd(tmp_path): 123 | run_copy( 124 | src_path=str(cloned_template), 125 | dst_path=".", 126 | data={ 127 | "odoo_version": supported_odoo_version, 128 | "postgres_version": DBVER_PER_ODOO[supported_odoo_version]["latest"], 129 | "backup_dst": "file:///here", 130 | "odoo_dbfilter": "^%d_%h$", 131 | }, 132 | vcs_ref="test", 133 | defaults=True, 134 | overwrite=True, 135 | unsafe=True, 136 | ) 137 | prod = DockerClient(compose_files=["prod.yaml"]).compose.config() 138 | assert prod.services["odoo"].environment["DB_FILTER"] == "^%d_%h$$" 139 | assert prod.services["backup"].environment["DBS_TO_INCLUDE"] == "^.*_.*$$" 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Doodba deployment](https://img.shields.io/badge/deployment-doodba-informational)][doodba] 2 | [![Copier template](https://img.shields.io/badge/template%20engine-copier-informational)][copier] 3 | [![Boost Software License 1.0](https://img.shields.io/badge/license-bsl--1.0-important)](COPYING) 4 | ![latest version](https://img.shields.io/github/v/release/Tecnativa/doodba-copier-template?sort=semver) 5 | ![test](https://github.com/Tecnativa/doodba-copier-template/workflows/test/badge.svg) 6 | ![lint](https://github.com/Tecnativa/doodba-copier-template/workflows/lint/badge.svg) 7 | [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://pre-commit.com/) 8 | 9 | # Doodba Copier Template 10 | 11 | This project lets you maintain [Odoo][] deployments based on [Doodba][] using 12 | [Copier][]. 13 | 14 |
15 | 16 | 17 | 18 | Table of contents 19 | 20 | - [Installation and Usage](#installation-and-usage) 21 | - [Install the dependencies](#install-the-dependencies) 22 | - [Use the template to generate your subproject](#use-the-template-to-generate-your-subproject) 23 | - [Getting updates for your subproject](#getting-updates-for-your-subproject) 24 | - [Using your subproject to build an Odoo deployment](#using-your-subproject-to-build-an-odoo-deployment) 25 | - [Python libraries](#python-libraries) 26 | - [Getting help](#getting-help) 27 | - [Contributing](#contributing) 28 | - [Credits](#credits) 29 | - [Footnotes](#footnotes) 30 | 31 | 32 | 33 |
34 | 35 | # Installation and Usage 36 | 37 | ## Install the dependencies 38 | 39 | This project itself is just the template, but you need to install these tools to use it: 40 | 41 | - Linux1 42 | - [copier][] 43 | - [Docker](https://docs.docker.com/) 44 | - [Compose V2 plugin](https://docs.docker.com/compose/install/) 45 | - [git](https://git-scm.com/) 2.24 or newer 46 | - [invoke](https://www.pyinvoke.org/) installed in Python 3.8.1+ (and the binary must be 47 | called `invoke` — beware if your distro installs it as `invoke3` or similar). 48 | - [pre-commit](https://pre-commit.com/) 49 | - [python](https://www.python.org/) 3.8.1+ 50 | - [venv](https://docs.python.org/3/library/venv.html) 51 | 52 | Install non-python apps with your distro's recommended package manager. The recommended 53 | way to install Python CLI apps is [pipx](https://pipxproject.github.io/pipx/): 54 | 55 | ```bash 56 | python3 -m pip install --user pipx 57 | pipx install copier 58 | pipx install invoke 59 | pipx install pre-commit 60 | pipx ensurepath 61 | ``` 62 | 63 | ## Use the template to generate your subproject 64 | 65 | Once you installed everything, you can now use Copier to copy this template: 66 | 67 | ```bash 68 | copier copy gh:Tecnativa/doodba-copier-template ~/path/to/your/subproject 69 | ``` 70 | 71 | Copier will ask you a lot of questions. Answer them to properly generate the template. 72 | 73 | Notes: 74 | 75 | - The backup service will not be deployed when using postgresql 9.6. 76 | 77 | ## Getting updates for your subproject 78 | 79 | ⚠️ If you come from 80 | [doodba-scaffolding](https://github.com/Tecnativa/doodba-scaffolding), please follow 81 | [the migration guide](docs/migrating-from-doodba-scaffolding.md). 82 | 83 | If you always used Copier with this project, getting last updates with Copier is simple: 84 | 85 | ```bash 86 | cd ~/path/to/your/downstream/scaffolding 87 | copier update --trust 88 | ``` 89 | 90 | Copier will ask you all questions again, but default values will be those you answered 91 | last time. Just hit Enter to accept those defaults, or change them if 92 | needed... or you can use `copier update --force --trust` instead to avoid answering 93 | again all things. 94 | 95 | Basically, read Copier docs and `copier --help-all` to know how to use it. 96 | 97 | # Using your subproject to build an Odoo deployment 98 | 99 | This is a big topic [documented separately](docs/daily-usage.md). 100 | 101 | ## Python libraries 102 | 103 | This project includes several libraries to add features to odoo scafoldings: 104 | 105 | - **openupgradelib**: Tools to manage upgrades in Odoo. 106 | - **unicodecsv**: Read and write CSV files with Unicode encoding support. 107 | - **unidecode**: Transliterates Unicode text to ASCII characters. 108 | - **jingtrang** (from Odoo 13 onwards): XML document validation using RELAX NG schemas. 109 | - **pathlib** (for Odoo < 11): Object-oriented path management for file system 110 | operations. 111 | 112 | # Getting help 113 | 114 | If your question is not answered in [our FAQ](docs/faq.md) or 115 | [Doodba's FAQ](https://github.com/Tecnativa/doodba#faq), 116 | [open an issue](CONTRIBUTING.md#issues) 117 | 118 | # Contributing 119 | 120 | See the [contribution guidelines](CONTRIBUTING.md). 121 | 122 | # Credits 123 | 124 | This project is maintained by: 125 | 126 | [![Tecnativa](https://www.tecnativa.com/r/H3p)](https://www.tecnativa.com/r/rIN) 127 | 128 | Also, special thanks to 129 | [our dear community contributors](https://github.com/Tecnativa/doodba-copier-template/graphs/contributors). 130 | 131 | # Footnotes 132 | 133 | 1 Any modern distro should work. Ubuntu and Fedora are officially supported. 134 | Other systems are not tested. If you're on Windows, you'll probably need WSL or a Linux 135 | VM to work with doodba without problems. If you use other systems and find a way to make 136 | these tools work, please consider [opening a PR](#contributing) to add some docs that 137 | might help others with your situation. 138 | 139 | [copier]: https://github.com/pykong/copier 140 | [doodba]: https://github.com/Tecnativa/doodba 141 | [odoo]: https://www.odoo.com/ 142 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml.jinja: -------------------------------------------------------------------------------- 1 | {# We cannot use the same pre-commit config as MQT's because paths don't match 2 | and it's designed only for Odoo 13.0+, so we design our own version, which 3 | should be as close as possible to MQT's -#} 4 | 5 | {#- This namespace holds repo versions #} 6 | {%- set proj_rev = namespace() %} 7 | {%- set proj_rev.ruff = "v0.1.3" %} 8 | {%- set proj_rev.pre_commit_hooks = "v4.5.0" %} 9 | {%- set proj_rev.odoo_pre_commit_hooks = "v0.0.29" %} 10 | {%- if odoo_version < 16 %} 11 | {%- set proj_rev.pylint_odoo = "v8.0.0" %} 12 | {%- elif odoo_version < 17 %} 13 | {%- set proj_rev.pylint_odoo = "v8.0.20" %} 14 | {%- elif odoo_version < 18 %} 15 | {%- set proj_rev.pylint_odoo = "v9.0.4" %} 16 | {%- elif odoo_version < 19 %} 17 | {%- set proj_rev.pylint_odoo = "v9.1.3" %} 18 | {%- set proj_rev.ruff = "v0.6.8" %} 19 | {%- set proj_rev.pre_commit_hooks = "v4.6.0" %} 20 | {%- set proj_rev.odoo_pre_commit_hooks = "v0.0.33" %} 21 | {%- else %} 22 | {%- set proj_rev.pre_commit_hooks = "v6.0.0" %} 23 | {%- set proj_rev.pylint_odoo = "v9.3.15" %} 24 | {%- set proj_rev.odoo_pre_commit_hooks = "v0.1.6" %} 25 | {%- set proj_rev.ruff = "v0.13.0" %} 26 | {%- endif -%} 27 | 28 | exclude: | 29 | (?x) 30 | # NOT INSTALLABLE ADDONS 31 | # END NOT INSTALLABLE ADDONS 32 | # Files and folders generated by bots, to avoid loops 33 | /static/description/index\.html$| 34 | # Files that fail if changed manually 35 | .*\.(diff|patch)$| 36 | # Library files can have extraneous formatting (even minimized) 37 | /static/(src/)?lib/| 38 | # You don't usually want a bot to modify your legal texts 39 | (LICENSE.*|COPYING.*) 40 | default_language_version: 41 | python: python3 42 | node: "18.17.1" 43 | repos: 44 | - repo: local 45 | hooks: 46 | - id: forbidden-files 47 | name: forbidden files 48 | entry: found forbidden files; remove them 49 | language: fail 50 | files: "\\.rej$" 51 | - &maintainer_tools 52 | repo: https://github.com/oca/maintainer-tools 53 | rev: f9b919b9868143135a9c9cb03021089cabba8223 54 | hooks: 55 | # update the NOT INSTALLABLE ADDONS section above 56 | - id: oca-update-pre-commit-excluded-addons 57 | args: 58 | - --addons-dir 59 | - odoo/custom/src/private 60 | {% if odoo_version >= 14 -%} 61 | - repo: https://github.com/OCA/odoo-pre-commit-hooks 62 | rev: {{ proj_rev.odoo_pre_commit_hooks }} 63 | hooks: 64 | - id: oca-checks-odoo-module 65 | - id: oca-checks-po 66 | args: ["--fix"] 67 | {% endif -%} 68 | {% if odoo_version >= 13 -%} 69 | - repo: https://github.com/astral-sh/ruff-pre-commit 70 | rev: {{ proj_rev.ruff }} 71 | hooks: 72 | - id: ruff 73 | args: [--fix, --exit-non-zero-on-fix] 74 | - id: ruff-format 75 | {% else -%} 76 | - repo: https://github.com/psf/black 77 | rev: 23.9.1 78 | hooks: 79 | - id: black 80 | additional_dependencies: ["click<=8.1.7"] 81 | - repo: https://github.com/PyCQA/isort 82 | rev: 5.12.0 83 | hooks: 84 | - id: isort 85 | name: isort except __init__.py 86 | args: [--settings, .] 87 | exclude: /__init__\.py$ 88 | - repo: https://github.com/pycqa/flake8 89 | rev: 6.1.0 90 | hooks: 91 | - id: flake8 92 | name: flake8 except __init__.py, __manifest__.py 93 | args: ["--extend-ignore=B023"] 94 | exclude: /__(?:init|manifest)__\.py$ 95 | additional_dependencies: ["flake8-bugbear==23.7.10", "importlib-metadata<=6.8.0"] 96 | - id: flake8 97 | name: flake8 only __init__.py 98 | args: ["--extend-ignore=F401"] # ignore unused imports in __init__.py 99 | files: /__init__\.py$ 100 | additional_dependencies: ["flake8-bugbear==23.7.10", "importlib-metadata<=6.8.0"] 101 | - id: flake8 102 | name: flake8 only __manifest__.py 103 | args: ["--extend-ignore=B018"] # ignore found useless expression in __manifest__.py 104 | files: /__manifest__\.py$ 105 | additional_dependencies: ["flake8-bugbear==23.7.10", "importlib-metadata<=6.8.0"] 106 | {% endif -%} 107 | - repo: https://github.com/pre-commit/mirrors-prettier 108 | # HACK https://github.com/prettier/prettier/issues/15696 109 | rev: v2.7.1 110 | hooks: 111 | - id: prettier 112 | name: prettier + plugin-xml 113 | args: [--plugin=@prettier/plugin-xml] 114 | additional_dependencies: 115 | # HACK https://github.com/prettier/pre-commit/issues/16#issuecomment-713474520 116 | - prettier@2.7.1 117 | - "@prettier/plugin-xml@v2.2.0" 118 | - repo: https://github.com/pre-commit/pre-commit-hooks 119 | rev: {{ proj_rev.pre_commit_hooks }} 120 | hooks: 121 | - id: trailing-whitespace 122 | - id: end-of-file-fixer 123 | {%- if odoo_version >= 11 %} 124 | - id: debug-statements 125 | {%- endif %} 126 | {% if odoo_version < 19 %} 127 | - id: fix-encoding-pragma 128 | {%- if odoo_version >= 11 %} 129 | args: ["--remove"] 130 | {%- endif %} 131 | {% endif %} 132 | - id: check-case-conflict 133 | - id: check-docstring-first 134 | - id: check-executables-have-shebangs 135 | - id: check-merge-conflict 136 | args: [--assume-in-merge] 137 | exclude: \.rst$ # HACK https://github.com/pre-commit/pre-commit-hooks/issues/985 138 | - id: check-symlinks 139 | - id: check-xml 140 | - id: mixed-line-ending 141 | args: ["--fix=lf"] 142 | - repo: https://github.com/OCA/pylint-odoo 143 | rev: {{ proj_rev.pylint_odoo }} 144 | hooks: 145 | - id: pylint_odoo 146 | name: pylint with optional checks 147 | args: 148 | - --rcfile=.pylintrc 149 | - --exit-zero 150 | verbose: true 151 | - id: pylint_odoo 152 | args: 153 | - --rcfile=.pylintrc-mandatory 154 | - repo: https://github.com/pre-commit/mirrors-eslint 155 | rev: v8.49.0 156 | hooks: 157 | - id: eslint 158 | verbose: true 159 | args: 160 | - --color 161 | - --fix 162 | - <<: *maintainer_tools 163 | hooks: 164 | # Generate readme is last, so its digest includes changes from above 165 | - id: oca-gen-addon-readme 166 | args: 167 | - --addons-dir=odoo/custom/src/private 168 | - --org-name={{ project_author }} 169 | - --repo-name={{ project_name }} 170 | - --convert-fragments-to-markdown 171 | - --gen-html 172 | - --if-fragments-changed 173 | - --branch={{ odoo_version }} 174 | - --template-filename=.module-readme.rst.j2 175 | -------------------------------------------------------------------------------- /migrations.py: -------------------------------------------------------------------------------- 1 | """Template migration scripts. 2 | 3 | This file is executed through invoke by copier when updating child projects. 4 | """ 5 | import re 6 | import shutil 7 | from pathlib import Path 8 | from unittest import mock 9 | 10 | from invoke import task 11 | from invoke.util import yaml 12 | 13 | 14 | def _load_yaml(yaml_path): 15 | """Load a yaml file.""" 16 | with open(yaml_path) as yaml_fd: 17 | # HACK https://stackoverflow.com/a/44875714/1468388 18 | # TODO Remove hack when https://github.com/pyinvoke/invoke/issues/708 is fixed 19 | with mock.patch.object( 20 | yaml.reader.Reader, 21 | "NON_PRINTABLE", 22 | re.compile( 23 | "[^\x09\x0A\x0D\x20-\x7E\x85\xA0-" 24 | "\uD7FF\uE000-\uFFFD\U00010000-\U0010FFFF]" 25 | ), 26 | ): 27 | return yaml.safe_load(yaml_fd) 28 | 29 | 30 | @task 31 | def from_doodba_scaffolding_to_copier(c): 32 | print("Removing remaining garbage from doodba-scaffolding.") 33 | shutil.rmtree(Path(".vscode", "doodba"), ignore_errors=True) 34 | garbage = ( 35 | Path(".travis.yml"), 36 | Path(".vscode", "doodbasetup.py"), 37 | Path("odoo", "custom", "src", "private", ".empty"), 38 | ) 39 | for path in garbage: 40 | try: 41 | path.unlink() 42 | except FileNotFoundError: 43 | pass 44 | # When using Copier >= 3.0.5, this file didn't get properly migrated 45 | editorconfig_file = Path(".editorconfig") 46 | editorconfig_contents = editorconfig_file.read_text() 47 | editorconfig_contents = editorconfig_contents.replace( 48 | "[*.yml]", "[*.{code-snippets,code-workspace,json,md,yaml,yml}{,.jinja}]", 1 49 | ) 50 | editorconfig_file.write_text(editorconfig_contents) 51 | 52 | 53 | @task 54 | def remove_odoo_auto_folder(c): 55 | """This folder makes no more sense for us. 56 | 57 | The `invoke develop` task now handles its creation, which is done with 58 | host user UID and GID to avoid problems. 59 | 60 | There's no need to have it in our code tree anymore. 61 | """ 62 | shutil.rmtree(Path("odoo", "auto"), ignore_errors=True) 63 | 64 | 65 | @task 66 | def remove_vscode_launch_and_tasks(c, dst_path): 67 | """Remove .vscode/{launch,tasks}.json file. 68 | 69 | Launch configurations are now generated in the doodba.*.code-workspace file. 70 | """ 71 | for fname in ("launch", "tasks"): 72 | garbage = Path(dst_path, ".vscode", f"{fname}.json") 73 | if garbage.is_file(): 74 | garbage.unlink() 75 | 76 | 77 | @task 78 | def remove_vscode_settings(c, dst_path): 79 | """Remove .vscode/{launch,tasks}.json file. 80 | 81 | Launch configurations are now generated in the doodba.*.code-workspace file. 82 | """ 83 | garbage = Path(dst_path, ".vscode", "settings.json") 84 | if garbage.is_file(): 85 | garbage.unlink() 86 | 87 | 88 | @task 89 | def update_domains_structure(c, dst_path, answers_rel_path): 90 | """Migrates from v1 to v2 domain structure. 91 | 92 | In template v1: 93 | 94 | - domain_prod was a str 95 | - domain_prod_alternatives was a list of str 96 | - domain_test was a str 97 | 98 | In template v2, we support multiple domains: 99 | 100 | - domains_prod is a list of dicts 101 | - domains_test is a list of dicts 102 | """ 103 | answers_path = Path(dst_path, answers_rel_path) 104 | answers_yaml = _load_yaml(answers_path) 105 | # Update domains_prod 106 | domain_prod = answers_yaml.pop("domain_prod", None) 107 | domain_prod_alternatives = answers_yaml.pop("domain_prod_alternatives", None) 108 | new_domains_prod = [] 109 | if domain_prod: 110 | new_domains_prod.append( 111 | {"hosts": [domain_prod], "cert_resolver": "letsencrypt"} 112 | ) 113 | if domain_prod_alternatives: 114 | new_domains_prod.append( 115 | { 116 | "hosts": domain_prod_alternatives, 117 | "cert_resolver": "letsencrypt", 118 | "redirect_to": domain_prod, 119 | } 120 | ) 121 | answers_yaml.setdefault("domains_prod", new_domains_prod) 122 | # Update domains_test 123 | domain_test = answers_yaml.pop("domain_test", None) 124 | new_domains_test = [] 125 | if domain_test: 126 | new_domains_test.append( 127 | {"hosts": [domain_test], "cert_resolver": "letsencrypt"} 128 | ) 129 | answers_yaml.setdefault("domains_test", new_domains_test) 130 | answers_path.write_text(yaml.safe_dump(answers_yaml)) 131 | # Remove .env file 132 | Path(dst_path, ".env").unlink() 133 | 134 | 135 | @task 136 | def update_no_license(c, dst_path, answers_rel_path): 137 | """Update projects with no license. 138 | 139 | In template version < 3.0.0, no license was `None`. In 3.0.0 it was changed 140 | to `""`, to make it compatible with Copier 6, but that made it not work 141 | fine with Copier 5. So, in version 3.0.1 it was changed to `"no_license"`. 142 | This value will always be a string, no matter the parser, and should make 143 | the parameter work fine in any Copier version. 144 | 145 | This migrates old answers to this new format. 146 | """ 147 | answers_path = Path(dst_path, answers_rel_path) 148 | answers_yaml = _load_yaml(answers_path) 149 | if ( 150 | not answers_yaml.get("project_license") 151 | or answers_yaml.get("project_license") == "no_license" 152 | ): 153 | answers_yaml["project_license"] = "no_license" 154 | answers_path.write_text(yaml.safe_dump(answers_yaml)) 155 | # Delete LICENSE if it existed but was empty 156 | license = Path(dst_path, "LICENSE") 157 | try: 158 | if not license.read_text().strip(): 159 | license.unlink() 160 | except FileNotFoundError: 161 | pass # LICENSE does not exist, and that's good 162 | 163 | 164 | @task 165 | def db_filter_prefix_default(c, dst_path, answers_rel_path): 166 | """Update projects with default DB filter including main DB prefix. 167 | 168 | In template version < 4.0.0, the default value for odoo_dbfilter was ".*" 169 | always. Starting with 4.0.0, the default value will be applied only to 170 | production environments and will include the main DB name as a prefix. 171 | 172 | Update answers for projects that didn't change the default. 173 | """ 174 | answers_path = Path(dst_path, answers_rel_path) 175 | answers_yaml = _load_yaml(answers_path) 176 | postgres_dbname = answers_yaml.get("postgres_dbname") 177 | if answers_yaml.get("odoo_dbfilter") == ".*" and postgres_dbname: 178 | # Replace odoo_dbfilter value in answers file 179 | answers_path.write_text( 180 | answers_path.read_text().replace( 181 | "odoo_dbfilter: .*", f"odoo_dbfilter: ^{postgres_dbname}" 182 | ) 183 | ) 184 | common_path = Path(dst_path, "common.yaml") 185 | common_path.write_text( 186 | common_path.read_text().replace( 187 | 'DBS_TO_INCLUDE: ".*"', f'DBS_TO_INCLUDE: "^{postgres_dbname}"' 188 | ) 189 | ) 190 | prod_path = Path(dst_path, "prod.yaml") 191 | prod_path.write_text( 192 | prod_path.read_text().replace( 193 | 'DB_FILTER: ".*"', f'DB_FILTER: "^{postgres_dbname}"' 194 | ) 195 | ) 196 | -------------------------------------------------------------------------------- /docs/migrating-from-doodba-scaffolding.md: -------------------------------------------------------------------------------- 1 | # Migrating From Doodba Scaffolding 2 | 3 |
4 | 5 | 6 | 7 | Table of contents 8 | 9 | - [Why we needed something better](#why-we-needed-something-better) 10 | - [How to transition to doodba-copier-template](#how-to-transition-to-doodba-copier-template) 11 | - [What changes for you now](#what-changes-for-you-now) 12 | 13 | 14 | 15 |
16 | 17 | Welcome to the migration guide for previous 18 | [doodba-scaffolding](https://github.com/Tecnativa/doodba-scaffolding) users. 19 | 20 | I know, I know... you are used to do `git pull scaffolding master` to update your 21 | templates, and now you wonder why now all is more complicated... Let me explain. 22 | 23 | ## Why we needed something better 24 | 25 | Before we started using [Copier](https://github.com/pykong/copier), 26 | [the official instructions](https://github.com/Tecnativa/doodba/blob/dbaaa2782a2d00e093063ebee3478c1d4093def3/README.md#skip-the-boring-parts) 27 | were, basically: 28 | 29 | 1. Git-clone the scaffolding. 30 | 1. Search for comments containing the string `XXX`. 31 | 1. Modify at will. 32 | 1. If you want to update your scaffolding, just pull again and resolve conflicts. 33 | 34 | This presented a lot of problems that Copier solves: 35 | 36 | 1. Your downstream project git history was flooded with commits from the upstream 37 | doodba-scaffolding project. 38 | 1. No possibility to apply logic to the template. This was becoming a problem as good 39 | tools arised. For example, pre-commit can add or remove the `# -*- coding: utf-8 -*-` 40 | comment to your source files, but the rule should be add in Python 2 (Odoo < 11.0) 41 | and remove in Python 3 (Odoo >= 11.0). But `.pre-commit-config.yaml` is a static 42 | file, so no way to include that 🤷. 43 | 1. No way to update a default value without breaking some production deployments. 44 | Example: 45 | [when we upgraded the default postgres version](https://github.com/Tecnativa/doodba/issues/67#issuecomment-413460188). 46 | 1. Adding a good README to this project would mean replicating it everywhere, so 47 | sometimes the barrier between doodba and doodba-scaffolding projects was blurry. 48 | 1. Possibly more problems. 49 | 50 | Now, with Copier, these problems are (or _can_ be) solved. However, now you must do some 51 | special extra steps to transition to the new workflow: 52 | 53 | ## How to transition to doodba-copier-template 54 | 55 | Now let's upgrade your downstream scaffolding to the latest version. This is required 56 | because the latest version _is the only one prepared to make transition to copier 57 | easier_. 58 | 59 | ```bash 60 | # In case you didn't do these before... 61 | cd ~/path/to/your/downstream/scaffolding 62 | git remote add scaffolding https://github.com/Tecnativa/doodba-scaffolding.git 63 | # The important one 64 | git pull scaffolding master 65 | ``` 66 | 67 | If you have git conflicts, solve them and commit: 68 | 69 | ```bash 70 | git mergetool # Or solve conflicts manually if you prefer 71 | git add . 72 | git merge --continue 73 | ``` 74 | 75 | You will never need again to pull from doodba-scaffolding, so let's remove that remote: 76 | 77 | ```bash 78 | git remote rm scaffolding 79 | ``` 80 | 81 | Now it's time to prepare this scaffolding to be upgraded. 82 | 83 | The first pain point you will experience is that **YAML and JSON files will be now 84 | indented using 2 spaces** instead of 4. Here's a little suggestion to avoid some nasty 85 | diffs when doing this upgrade, but it will cost you 1 commit. If you prefer, you can 86 | choose to skip this step and solve conflicts manually later, but be aware they'll be _A 87 | LOT_: 88 | 89 | ```bash 90 | sed -i 's/ / /g' .vscode/*.{json,code-snippets} *.yaml odoo/custom/src/*.yaml 91 | git commit -am '[DCK] Indent YAML and JSON files with 2 spaces' 92 | ``` 93 | 94 | OK, you're ready to use copier for the first time, so 95 | [make sure you installed all you need](../README.md#installation-and-usage) and 96 | continue: 97 | 98 | ```bash 99 | copier update 100 | ``` 101 | 102 | Copier will ask you a lot of questions. **Your answers must match what you already have 103 | in your scaffolding**, or otherwise the update could be problematic. 104 | 105 |
106 | Copier asks too many questions; I want a faster but less secure way to do it. 107 | 108 | You can use [this little script](./scaffolding2copier.sh) to make your transition 109 | easier. It will _try_ to get values from your current scaffolding and apply them to 110 | copier. **Take it as just a simple helper, but this doesn't save you the transition 111 | responsibility**, because the possible customizations in a scaffolding are basically 112 | endless. Inspect its code to understand the environment variables that can alter its 113 | behavior. Run it like this: 114 | 115 | ```bash 116 | bash -c 'source <(curl -sSL https://raw.githubusercontent.com/Tecnativa/doodba-copier-template/stable/docs/scaffolding2copier.sh)' 117 | ``` 118 | 119 | If anything goes wrong, reset and use the manual way: 120 | 121 | ```bash 122 | git reset --hard 123 | git clean -ffd 124 | ``` 125 | 126 |
127 | 128 | Now, it's time for conflict resolution (again): 129 | 130 | - Copier tried to solve most conflicts for you, but it saves what it can't solve in 131 | `*.rej` files. Those are forbidden, but meaningful. If there's any, it means there's 132 | some unresolved diff you should review manually. Search for them manually and **review 133 | those conflicts**. When you finish, **remove those files** or you won't be able to 134 | commit. 135 | 136 | - Apart from that, review all `git diff`. It's a lot! But it will help you. 137 | [Read below to understand that diff](#what-changes-for-you-now). 138 | 139 | You can use `git difftool` to launch your favorite diff tool and inspect it more 140 | comfortably. 141 | 142 | After you've finished solving all conflicts and are happy with the result, commit it: 143 | 144 | ```bash 145 | git add . 146 | # This command could fail if pre-commit reformats any files; if so, repeat it twice 147 | git commit -am '[DCK] Upgrade from doodba-scaffolding to doodba-copier-template' 148 | # Format all other files (private modules, custom configs...) and commit that separately 149 | pre-commit run -a 150 | git commit -am '[IMP] 1st pre-commit execution' 151 | ``` 152 | 153 | ⚠️ Read 154 | [this warning about XML whitespace](faq.md#why-xml-is-broken-after-running-pre-commit) 155 | ⚠️ 156 | 157 | Your transition is finished! 🎉 158 | 159 | ## What changes for you now 160 | 161 | After finishing, you will notice some important differences: 162 | 163 | - Many `XXX` comments are removed because now there's no need for them. 164 | - `LICENSE` might have changed if you didn't provide a valid value in the 165 | `copier update` step above. 166 | - In `.env` and `prod.yaml`, `BACKUP_S3_BUCKET` is replaced for `BACKUP_DST`, which is 167 | more generic. 168 | - In `prod.yaml`, `traefik.alt` labels are now `traefik.alt-0`, because now several alt 169 | domains are supported. 170 | - `README.md` is completely changed. 171 | - A new `.copier-answers.yml` file has been created, with all your answers. It is 172 | important that you **don't change this file manually and add it to your next commit**, 173 | because it will make further updates work as expected. 174 | - You have a lot of new configs for linters and formatters, including the almighty 175 | `.pre-commit-config.yaml` file, which is enabled by default for you. Your code will 176 | look awesome now! 177 | - OTOH some other configs and helpers are removed, namely: 178 | - `.vscode/doodba/` 179 | - `.vscode/doodbasetup.py` 180 | - `.travis.yml` is removed to avoid your child project including some unnecessary 181 | configurations, but in case you modified it before and need it, just restore it. 182 | - There's a new `tasks.py` file ready to be used from the `invoke` command that you 183 | previously installed. Let's use it! 184 | 185 | ```bash 186 | # This will set up your development environment (although it's done for you already 😉) 187 | invoke develop 188 | # This will download git code 189 | invoke git-aggregate 190 | # List all other tasks 191 | invoke --list 192 | ``` 193 | 194 | In case you use the recommended VSCode IDE to develop, you'll notice additional 195 | differences: 196 | 197 | - New plugins are recommended. It's redundant, but I recommend you to install them. 198 | - Most tasks are removed. Instead, use `invoke` now, which works no matter what editor 199 | you use. Of course, one of the new recommended extensions adds a command to your 200 | editor called _Invoke a task_, which runs `invoke` for you. 201 | -------------------------------------------------------------------------------- /tests/test_routing.py: -------------------------------------------------------------------------------- 1 | import time 2 | import uuid 3 | from pathlib import Path 4 | 5 | import pytest 6 | import requests 7 | from copier import run_copy 8 | from packaging import version 9 | from plumbum import local 10 | from python_on_whales import DockerClient 11 | 12 | from .conftest import DBVER_PER_ODOO 13 | 14 | 15 | @pytest.mark.parametrize("environment", ("test", "prod")) 16 | def test_multiple_domains( 17 | cloned_template: Path, 18 | supported_odoo_version: float, 19 | tmp_path: Path, 20 | traefik_host: dict, 21 | environment: str, 22 | ): 23 | """Test multiple domains are produced properly.""" 24 | base_domain = traefik_host["hostname"] 25 | base_path = f"{base_domain}/web/login" 26 | # XXX Remove traefik1 specific stuff some day 27 | is_traefik1 = version.parse(traefik_host["traefik_version"]) < version.parse("2") 28 | is_traefik3 = version.parse(traefik_host["traefik_version"]) >= version.parse("3") 29 | data = { 30 | "odoo_listdb": True, 31 | "traefik_version": int( 32 | str(version.parse(traefik_host["traefik_version"])).split(".")[0] 33 | ), 34 | "odoo_version": supported_odoo_version, 35 | "postgres_version": DBVER_PER_ODOO[supported_odoo_version]["latest"], 36 | "paths_without_crawlers": ["/web/login", "/web/database"], 37 | "project_name": uuid.uuid4().hex, 38 | f"domains_{environment}": [ 39 | # main0 has no TLS 40 | {"hosts": [f"main0.{base_domain}"], "cert_resolver": False}, 41 | { 42 | "hosts": [f"alt0.main0.{base_domain}", f"alt1.main0.{base_domain}"], 43 | "cert_resolver": None, 44 | "redirect_to": f"main0.{base_domain}", 45 | }, 46 | # main1 has self-signed certificates 47 | {"hosts": [f"main1.{base_domain}"], "cert_resolver": True}, 48 | { 49 | "hosts": [f"alt0.main1.{base_domain}", f"alt1.main1.{base_domain}"], 50 | "cert_resolver": True, 51 | "redirect_to": f"main1.{base_domain}", 52 | "redirect_permanent": True, 53 | }, 54 | # main2 only serves certain routes 55 | { 56 | "hosts": [f"main2.{base_domain}"], 57 | "path_prefixes": ["/insecure/"], 58 | "entrypoints": ["web-insecure"], 59 | "cert_resolver": False, 60 | }, 61 | # main3 only serves certain routes in web-alt entrypoint 62 | { 63 | "hosts": [f"main3.{base_domain}"], 64 | "path_prefixes": ["/alt/"], 65 | "entrypoints": ["web-alt"], 66 | "cert_resolver": False, 67 | }, 68 | ], 69 | } 70 | if supported_odoo_version < 16: 71 | data["postgres_version"] = 13 72 | dc = DockerClient(compose_files=[f"{environment}.yaml"]) 73 | with local.cwd(tmp_path): 74 | run_copy( 75 | src_path=str(cloned_template), 76 | dst_path=".", 77 | data=data, 78 | vcs_ref="test", 79 | defaults=True, 80 | overwrite=True, 81 | unsafe=True, 82 | ) 83 | # Check if Odoo options were passed correctly 84 | docker_compose_config = dc.compose.config() 85 | assert docker_compose_config.services["odoo"].environment["LIST_DB"] == "true" 86 | try: 87 | dc.compose.build() 88 | dc.compose.run( 89 | "odoo", 90 | command=["--stop-after-init", "-i", "base"], 91 | remove=True, 92 | ) 93 | dc.compose.up(detach=True) 94 | time.sleep(10) 95 | # XXX Remove all Traefik 1 tests once it disappears 96 | if is_traefik1: 97 | # main0, globally redirected to TLS 98 | response = requests.get(f"http://main0.{base_path}", verify=False) 99 | assert response.ok 100 | assert response.url == f"https://main0.{base_domain}:443/web/login" 101 | assert response.headers["X-Robots-Tag"] == "noindex, nofollow" 102 | # alt0 and alt1, globally redirected to TLS 103 | for alt_num in range(2): 104 | response = requests.get( 105 | f"http://alt{alt_num}.main0.{base_path}", verify=False 106 | ) 107 | assert response.ok 108 | assert response.url == f"https://main0.{base_path}" 109 | assert response.history[0].status_code == 302 110 | # main2 serves https on port 80; returns a 404 from Traefik (not from 111 | # Odoo) with global HTTPS redirection 112 | bad_response = requests.get( 113 | f"http://main2.{base_domain}/insecure/path", 114 | verify=False, 115 | ) 116 | assert not bad_response.ok 117 | assert bad_response.status_code == 404 118 | assert "Server" not in bad_response.headers # 404 comes from Traefik 119 | assert ( 120 | bad_response.url == f"https://main2.{base_domain}:443/insecure/path" 121 | ) 122 | else: 123 | # main0, no TLS 124 | if not is_traefik3: 125 | response = requests.get(f"http://main0.{base_path}") 126 | assert response.ok 127 | assert response.url == f"http://main0.{base_path}" 128 | assert response.headers["X-Robots-Tag"] == "noindex, nofollow" 129 | # alt0 and alt1, no TLS 130 | for alt_num in range(2): 131 | response = requests.get( 132 | f"http://alt{alt_num}.main0.{base_path}" 133 | ) 134 | assert response.ok 135 | assert response.url == f"http://main0.{base_path}" 136 | assert response.history[0].status_code == 302 137 | # main2 serves https on port 80; returns a 404 from Odoo (not from 138 | # Traefik) without HTTPS redirection 139 | bad_response = requests.get( 140 | f"http://main2.{base_domain}/insecure/path", 141 | verify=False, 142 | ) 143 | assert not bad_response.ok 144 | assert bad_response.status_code == 404 145 | assert "Werkzeug" in bad_response.headers.get("Server") 146 | assert bad_response.url == f"http://main2.{base_domain}/insecure/path" 147 | # main3 cannot find /web on port 8080; no HTTPS redirection 148 | bad_response = requests.get( 149 | f"http://main3.{base_domain}:8080/web", 150 | ) 151 | assert not bad_response.ok 152 | assert bad_response.status_code == 404 153 | assert "Server" not in bad_response.headers # 404 comes from Traefik 154 | assert bad_response.url == f"http://main3.{base_domain}:8080/web" 155 | # main3 will route to odoo in /alt/foo but fail with 404 from there, no HTTPS 156 | bad_response = requests.get( 157 | f"http://main3.{base_domain}:8080/alt/foo", 158 | ) 159 | assert not bad_response.ok 160 | assert bad_response.status_code == 404 161 | assert "Werkzeug" in bad_response.headers.get("Server") 162 | assert bad_response.url == f"http://main3.{base_domain}:8080/alt/foo" 163 | # main1, with self-signed TLS 164 | response = requests.get(f"http://main1.{base_path}", verify=False) 165 | assert response.ok 166 | assert response.url == ( 167 | f"https://main1.{base_domain}:443/web/login" 168 | if is_traefik1 169 | else f"https://main1.{base_path}" 170 | ) 171 | if not is_traefik3: 172 | assert response.headers["X-Robots-Tag"] == "noindex, nofollow" 173 | # alt0 and alt1, with self-signed TLS 174 | for alt_num in range(2): 175 | response = requests.get( 176 | f"http://alt{alt_num}.main1.{base_domain}/web/database/selector", 177 | verify=False, 178 | ) 179 | assert response.ok 180 | assert ( 181 | response.url 182 | == f"https://main1.{base_domain}/web/database/selector" 183 | ) 184 | assert response.headers["X-Robots-Tag"] == "noindex, nofollow" 185 | # Search for a response in the chain with the 301 return code 186 | # as several will be made during the redirection 187 | assert filter(lambda r: r.status_code == 301, response.history) 188 | # missing, which fails with Traefik 404, both with and without TLS 189 | bad_response = requests.get( 190 | f"http://missing.{base_path}", verify=not is_traefik1 191 | ) 192 | assert bad_response.status_code == 404 193 | assert "Server" not in bad_response.headers 194 | bad_response = requests.get(f"https://missing.{base_path}", verify=False) 195 | assert bad_response.status_code == 404 196 | assert "Server" not in bad_response.headers 197 | finally: 198 | dc.compose.down(remove_images="local", remove_orphans=True) 199 | -------------------------------------------------------------------------------- /_traefik1_paths_labels.yml.jinja: -------------------------------------------------------------------------------- 1 | {%- import "_macros.jinja" as macros -%} 2 | {# 3 | Note: indentation of 6 spaces is important because that's the depth level 4 | of container labels in a docker-compose file. 5 | #} 6 | 7 | {# Echo all path prefixes in a Traefik rule #} 8 | {%- macro path_prefix_rule(path_prefixes) %} 9 | {%- set _ns = namespace(with_slash=[], without_slash=[]) %} 10 | {%- for prefix in path_prefixes %} 11 | {%- if prefix.endswith("/") %} 12 | {%- set _ns.with_slash = _ns.with_slash + [prefix] %} 13 | {%- else %} 14 | {%- set _ns.with_slash = _ns.with_slash + ["%s/" % prefix] %} 15 | {%- set _ns.without_slash = _ns.without_slash + [prefix] %} 16 | {%- endif %} 17 | {%- endfor -%} 18 | (PathPrefix( 19 | {%- for path in _ns.with_slash -%} 20 | `{{ path }}` 21 | {%- if not loop.last %}, {% endif %} 22 | {%- endfor -%} 23 | ) 24 | {%- if _ns.without_slash %} || Path( 25 | {%- for path in _ns.without_slash -%} 26 | `{{ path }}` 27 | {%- if not loop.last %}, {% endif %} 28 | {%- endfor -%} 29 | ) 30 | {%- endif -%} 31 | ) 32 | {%- endmacro %} 33 | 34 | {# Echo all domains of a group, in a Traefik rule #} 35 | {%- macro domains_rule(domain_group) -%} 36 | Host: 37 | {%- for host in domain_group.hosts -%} 38 | {{ host }} 39 | {%- if not loop.last %}, {% endif %} 40 | {%- endfor -%} 41 | {%- if domain_group.path_prefixes %} && {{ path_prefix_rule(domain_group.path_prefixes) }} 42 | {%- endif %} 43 | {%- endmacro %} 44 | 45 | 46 | {%- macro key(project_name, odoo_version, suffix) %} 47 | {{- '%s-%.1f-%s'|format(project_name, odoo_version, suffix)|replace('.', '-') }} 48 | {%- endmacro %} 49 | 50 | {#- Basic labels for a single router #} 51 | {%- macro router(domain_group, key, suffix, rule=none, service=none, middlewares=(), priority=none) %} 52 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.rule: 53 | {{ rule|default(domains_rule(domain_group), true) }} 54 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.service: 55 | {{ key }}-{{ service|default("main", true) }} 56 | {%- if domain_group.entrypoints %} 57 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.entrypoints: 58 | {{ domain_group.entrypoints|sort|join(", ") }} 59 | {%- endif %} 60 | {%- if middlewares %} 61 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.middlewares: 62 | {{ key }}-{{ middlewares|sort|join(", %s-" % key) }} 63 | {%- endif %} 64 | 65 | {%- if priority %} 66 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.priority: {{ priority }} 67 | {%- endif %} 68 | 69 | {%- if domain_group.cert_resolver %} 70 | 71 | {#- Add TLS configuraiton #} 72 | {%- if suffix.endswith("-secure") %} 73 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.tls: "true" 74 | {%- if domain_group.cert_resolver is string %} 75 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.tls.certResolver: 76 | {{ domain_group.cert_resolver }} 77 | {%- endif %} 78 | 79 | {#- Create TLS-only router; 80 | HACK https://github.com/containous/traefik/issues/7235 #} 81 | {%- else %} 82 | {{- router(domain_group, key, "%s-secure" % suffix, rule, service, middlewares, priority) }} 83 | {%- endif %} 84 | 85 | {%- endif %} 86 | {%- endmacro %} 87 | 88 | {%- macro common_middlewares(key, cidr_whitelist) %} 89 | {#- Common middlewares #} 90 | traefik.http.middlewares.{{ key }}-buffering.buffering.retryExpression: 91 | IsNetworkError() && Attempts() < 5 92 | traefik.http.middlewares.{{ key }}-compress.compress: "true" 93 | ? traefik.http.middlewares.{{ key }}-forbid-crawlers.headers.customResponseHeaders.X-Robots-Tag 94 | : "noindex, nofollow" 95 | traefik.http.middlewares.{{ key }}-addSTS.headers.forceSTSHeader: "true" 96 | traefik.http.middlewares.{{ key }}-forceSecure.redirectScheme.scheme: https 97 | traefik.http.middlewares.{{ key }}-forceSecure.redirectScheme.permanent: "true" 98 | {%- if cidr_whitelist %} 99 | {#- Declare whitelist middleware #} 100 | ? traefik.http.middlewares.{{ key }}-whitelist.IPWhiteList.sourceRange 101 | : {% for cidr in cidr_whitelist -%} 102 | {{ cidr }}{% if not loop.last %}, {% endif %} 103 | {%- endfor %} 104 | {%- endif %} 105 | {%- endmacro %} 106 | 107 | {%- macro odoo(domain_groups_list, cidr_whitelist, key, odoo_version, 108 | paths_without_crawlers, paths_with_crawlers, project_name) %} 109 | {#- Service #} 110 | traefik.http.services.{{ key }}-main.loadbalancer.server.port: 8069 111 | traefik.http.services.{{ key }}-longpolling.loadbalancer.server.port: 8072 112 | 113 | {%- call(domain_group) macros.domains_loop_grouped(domain_groups_list) %} 114 | {#- Remember basic middlewares for this domain group #} 115 | {%- set _ns = namespace(basic_middlewares=[]) -%} 116 | {%- if cidr_whitelist %} 117 | {%- set _ns.basic_middlewares = _ns.basic_middlewares + ["whitelist"] %} 118 | {%- endif %} 119 | {%- if domain_group.cert_resolver %} 120 | {%- set _ns.basic_middlewares = _ns.basic_middlewares + ["addSTS", "forceSecure"] %} 121 | {%- endif %} 122 | 123 | {%- if domain_group.redirect_to %} 124 | {#- Redirections #} 125 | {%- if domain_group.redirect_permanent %} 126 | traefik.http.middlewares.{{ key }}-redirect-{{ domain_group.loop.index0 }}.redirectRegex.permanent: "true" 127 | {%- endif %} 128 | traefik.http.middlewares.{{ key }}-redirect-{{ domain_group.loop.index0 }}.redirectRegex.regex: ^(.*)://([^/]+)/(.*)$$ 129 | traefik.http.middlewares.{{ key }}-redirect-{{ domain_group.loop.index0 }}.redirectRegex.replacement: $$1://{{ domain_group.redirect_to }}/$$3 130 | {{- 131 | router( 132 | domain_group=domain_group, 133 | key=key, 134 | suffix="redirect", 135 | middlewares=_ns.basic_middlewares + [ 136 | "compress", 137 | "redirect-%d" % domain_group.loop.index0, 138 | ], 139 | priority=10, 140 | ) 141 | }} 142 | {%- else %} 143 | 144 | {#- When removing crawlers for /, this router is the same as forbiddenCrawlers, so no need to duplicate #} 145 | {%- if paths_without_crawlers != ["/"] or domain_group.path_prefixes %} 146 | {#- Main router #} 147 | {{- 148 | router( 149 | domain_group=domain_group, 150 | key=key, 151 | suffix="main", 152 | middlewares=_ns.basic_middlewares + [ 153 | "buffering", 154 | "compress", 155 | ], 156 | priority=1, 157 | ) 158 | }} 159 | {%- endif %} 160 | 161 | {#- Longpolling router #} 162 | {%- if not domain_group.path_prefixes %} 163 | {%- set longpolling_route = "PathPrefix(`/longpolling/`)" if odoo_version < 16 else "Path(`/websocket`)" -%} 164 | {{- 165 | router( 166 | domain_group=domain_group, 167 | key=key, 168 | suffix="longpolling", 169 | rule="%s && %s" % (domains_rule(domain_group), longpolling_route), 170 | service="longpolling", 171 | middlewares=_ns.basic_middlewares, 172 | priority=20, 173 | ) 174 | }} 175 | {%- endif %} 176 | 177 | {#- Forbidden crawlers router #} 178 | {%- if paths_without_crawlers and not domain_group.path_prefixes %} 179 | {{- 180 | router( 181 | domain_group=domain_group, 182 | key=key, 183 | suffix="forbiddenCrawlers", 184 | rule="%s && %s" % ( 185 | domains_rule(domain_group), 186 | path_prefix_rule(paths_without_crawlers), 187 | ), 188 | middlewares=_ns.basic_middlewares + [ 189 | "buffering", 190 | "compress", 191 | "forbid-crawlers", 192 | ], 193 | priority=10, 194 | ) 195 | }} 196 | {%- endif %} 197 | {%- if paths_with_crawlers and not domain_group.path_prefixes %} 198 | {{- 199 | router( 200 | domain_group=domain_group, 201 | key=key, 202 | suffix="allowedCrawlers", 203 | rule="%s && %s" % ( 204 | domains_rule(domain_group), 205 | path_prefix_rule(paths_with_crawlers), 206 | ), 207 | middlewares=_ns.basic_middlewares + [ 208 | "buffering", 209 | "compress", 210 | ], 211 | priority=100, 212 | ) 213 | }} 214 | {%- endif %} 215 | {%- endif %} 216 | {%- endcall %} 217 | {%- endmacro %} 218 | 219 | {#- Basic labels for a single router #} 220 | {%- macro router_tcp(domain_group, key, suffix, rule=none, service=none, middlewares=(), port=none) %} 221 | {%- if port %} 222 | traefik.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.port: {{ port }} 223 | {%- endif %} 224 | traefik.tcp.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.rule: 225 | {{ rule|default(domains_rule(domain_group), true) }} 226 | traefik.tcp.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.service: 227 | {{ key }}-{{ service|default("main", true) }} 228 | {%- if domain_group.entrypoints %} 229 | traefik.tcp.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.entrypoints: 230 | {{ domain_group.entrypoints|sort|join(", ") }} 231 | {%- endif %} 232 | {%- if middlewares %} 233 | traefik.tcp.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.middlewares: 234 | {{ key }}-{{ middlewares|sort|join(", %s-" % key) }} 235 | {%- endif %} 236 | {%- endmacro %} 237 | 238 | {%- macro database(domain_groups_list, cidr_whitelist, key, port, project_name) %} 239 | {#- Service #} 240 | traefik.tcp.services.{{ key }}-database.loadbalancer.server.port: 5432 241 | 242 | {%- if cidr_whitelist %} 243 | {#- Declare whitelist middleware #} 244 | ? traefik.tcp.middlewares.{{ key }}-whitelist.IPWhiteList.sourceRange 245 | : {% for cidr in cidr_whitelist -%} 246 | {{ cidr }}{% if not loop.last %}, {% endif %} 247 | {%- endfor %} 248 | {%- endif %} 249 | 250 | {%- call(domain_group) macros.domains_loop_grouped(domain_groups_list) %} 251 | {#- Remember basic middlewares for this domain group #} 252 | {%- set _ns = namespace(basic_middlewares=[]) -%} 253 | {%- if cidr_whitelist %} 254 | {%- set _ns.basic_middlewares = _ns.basic_middlewares + ["whitelist"] %} 255 | {%- endif %} 256 | 257 | {#- database router #} 258 | {{- 259 | router_tcp( 260 | domain_group=domain_group, 261 | key=key, 262 | suffix="database", 263 | service="database", 264 | middlewares=_ns.basic_middlewares, 265 | port=port, 266 | ) 267 | }} 268 | {%- endcall %} 269 | {%- endmacro %} 270 | -------------------------------------------------------------------------------- /_traefik2_labels.yml.jinja: -------------------------------------------------------------------------------- 1 | {%- import "_macros.jinja" as macros -%} 2 | {# 3 | Note: indentation of 6 spaces is important because that's the depth level 4 | of container labels in a docker-compose file. 5 | #} 6 | 7 | {# Echo all path prefixes in a Traefik rule #} 8 | {%- macro path_prefix_rule(path_prefixes) %} 9 | {%- set _ns = namespace(with_slash=[], without_slash=[]) %} 10 | {%- for prefix in path_prefixes %} 11 | {%- if prefix.endswith("/") %} 12 | {%- set _ns.with_slash = _ns.with_slash + [prefix] %} 13 | {%- else %} 14 | {%- set _ns.with_slash = _ns.with_slash + ["%s/" % prefix] %} 15 | {%- set _ns.without_slash = _ns.without_slash + [prefix] %} 16 | {%- endif %} 17 | {%- endfor -%} 18 | (PathPrefix( 19 | {%- for path in _ns.with_slash -%} 20 | `{{ path }}` 21 | {%- if not loop.last %}, {% endif %} 22 | {%- endfor -%} 23 | ) 24 | {%- if _ns.without_slash %} || Path( 25 | {%- for path in _ns.without_slash -%} 26 | `{{ path }}` 27 | {%- if not loop.last %}, {% endif %} 28 | {%- endfor -%} 29 | ) 30 | {%- endif -%} 31 | ) 32 | {%- endmacro %} 33 | 34 | {# Echo all domains of a group, in a Traefik rule #} 35 | {%- macro domains_rule(domain_group) -%} 36 | Host( 37 | {%- for host in domain_group.hosts -%} 38 | `{{ host }}` 39 | {%- if not loop.last %}, {% endif %} 40 | {%- endfor -%} 41 | ) 42 | {%- if domain_group.path_prefixes %} && {{ path_prefix_rule(domain_group.path_prefixes) }} 43 | {%- endif %} 44 | {%- endmacro %} 45 | 46 | {%- macro key(project_name, odoo_version, suffix) %} 47 | {{- '%s-%.1f-%s'|format(project_name, odoo_version, suffix)|replace('.', '-') }} 48 | {%- endmacro %} 49 | 50 | {#- Basic labels for a single router #} 51 | {%- macro router(domain_group, key, suffix, rule=none, service=none, middlewares=(), priority=none) %} 52 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.rule: 53 | {{ rule|default(domains_rule(domain_group), true) }} 54 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.service: 55 | {{ key }}-{{ service|default("main", true) }} 56 | {%- if domain_group.entrypoints %} 57 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.entrypoints: 58 | {{ domain_group.entrypoints|sort|join(", ") }} 59 | {%- endif %} 60 | {%- if middlewares %} 61 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.middlewares: 62 | {{ key }}-{{ middlewares|sort|join(", %s-" % key) }} 63 | {%- endif %} 64 | 65 | {%- if priority %} 66 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.priority: {{ priority }} 67 | {%- endif %} 68 | 69 | {%- if domain_group.cert_resolver %} 70 | 71 | {#- Add TLS configuraiton #} 72 | {%- if suffix.endswith("-secure") %} 73 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.tls: "true" 74 | {%- if domain_group.cert_resolver is string %} 75 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.tls.certResolver: 76 | {{ domain_group.cert_resolver }} 77 | {%- endif %} 78 | 79 | {#- Create TLS-only router; 80 | HACK https://github.com/containous/traefik/issues/7235 #} 81 | {%- else %} 82 | {{- router(domain_group, key, "%s-secure" % suffix, rule, service, middlewares, priority) }} 83 | {%- endif %} 84 | 85 | {%- endif %} 86 | {%- endmacro %} 87 | 88 | {%- macro custom_ws_middleware(key) -%} 89 | {#- Force X-Forwarded-Proto to https for websockets #} 90 | traefik.http.middlewares.{{ key }}-override-ws-headers.headers.customRequestHeaders.X-Forwarded-Proto: "https" 91 | {%- endmacro %} 92 | 93 | {%- macro common_middlewares(key, cidr_whitelist) %} 94 | {#- Common middlewares #} 95 | traefik.http.middlewares.{{ key }}-buffering.buffering.retryExpression: 96 | IsNetworkError() && Attempts() < 5 97 | traefik.http.middlewares.{{ key }}-compress.compress: "true" 98 | ? traefik.http.middlewares.{{ key }}-forbid-crawlers.headers.customResponseHeaders.X-Robots-Tag 99 | : "noindex, nofollow" 100 | traefik.http.middlewares.{{ key }}-addSTS.headers.forceSTSHeader: "true" 101 | traefik.http.middlewares.{{ key }}-forceSecure.redirectScheme.scheme: https 102 | traefik.http.middlewares.{{ key }}-forceSecure.redirectScheme.permanent: "true" 103 | {%- if cidr_whitelist %} 104 | {#- Declare whitelist middleware #} 105 | ? traefik.http.middlewares.{{ key }}-whitelist.IPWhiteList.sourceRange 106 | : {% for cidr in cidr_whitelist -%} 107 | {{ cidr }}{% if not loop.last %}, {% endif %} 108 | {%- endfor %} 109 | {%- endif %} 110 | {%- endmacro %} 111 | 112 | {%- macro odoo(domain_groups_list, cidr_whitelist, key, odoo_version, 113 | paths_without_crawlers, paths_with_crawlers, project_name) %} 114 | {%- if odoo_version >= 16%} 115 | {{- custom_ws_middleware(key) }} 116 | {%- endif %} 117 | {#- Service #} 118 | traefik.http.services.{{ key }}-main.loadbalancer.server.port: 8069 119 | traefik.http.services.{{ key }}-longpolling.loadbalancer.server.port: 8072 120 | 121 | {%- call(domain_group) macros.domains_loop_grouped(domain_groups_list) %} 122 | {#- Remember basic middlewares for this domain group #} 123 | {%- set _ns = namespace(basic_middlewares=[]) -%} 124 | {%- if cidr_whitelist %} 125 | {%- set _ns.basic_middlewares = _ns.basic_middlewares + ["whitelist"] %} 126 | {%- endif %} 127 | {%- if domain_group.cert_resolver %} 128 | {%- set _ns.basic_middlewares = _ns.basic_middlewares + ["addSTS", "forceSecure"] %} 129 | {%- endif %} 130 | 131 | {%- if domain_group.redirect_to %} 132 | {#- Redirections #} 133 | {%- if domain_group.redirect_permanent %} 134 | traefik.http.middlewares.{{ key }}-redirect-{{ domain_group.loop.index0 }}.redirectRegex.permanent: "true" 135 | {%- endif %} 136 | traefik.http.middlewares.{{ key }}-redirect-{{ domain_group.loop.index0 }}.redirectRegex.regex: ^(.*)://([^/]+)/(.*)$$ 137 | traefik.http.middlewares.{{ key }}-redirect-{{ domain_group.loop.index0 }}.redirectRegex.replacement: $$1://{{ domain_group.redirect_to }}/$$3 138 | {{- 139 | router( 140 | domain_group=domain_group, 141 | key=key, 142 | suffix="redirect", 143 | middlewares=_ns.basic_middlewares + [ 144 | "compress", 145 | "redirect-%d" % domain_group.loop.index0, 146 | ], 147 | priority=10, 148 | ) 149 | }} 150 | {%- else %} 151 | 152 | {#- When removing crawlers for /, this router is the same as forbiddenCrawlers, so no need to duplicate #} 153 | {%- if paths_without_crawlers != ["/"] or domain_group.path_prefixes %} 154 | {#- Main router #} 155 | {{- 156 | router( 157 | domain_group=domain_group, 158 | key=key, 159 | suffix="main", 160 | middlewares=_ns.basic_middlewares + [ 161 | "buffering", 162 | "compress", 163 | ], 164 | priority=1, 165 | ) 166 | }} 167 | {%- endif %} 168 | 169 | {#- Longpolling router #} 170 | {%- if not domain_group.path_prefixes %} 171 | {%- set longpolling_route = "PathPrefix(`/longpolling/`)" if odoo_version < 16 else "Path(`/websocket`)" -%} 172 | {%- if odoo_version >= 16 %} 173 | {%- set ws_middlewares = _ns.basic_middlewares + ["override-ws-headers"] %} 174 | {%- else %} 175 | {%- set ws_middlewares = _ns.basic_middlewares %} 176 | {%- endif %} 177 | {{- 178 | router( 179 | domain_group=domain_group, 180 | key=key, 181 | suffix="longpolling", 182 | rule="%s && %s" % (domains_rule(domain_group), longpolling_route), 183 | service="longpolling", 184 | middlewares=ws_middlewares, 185 | priority=20, 186 | ) 187 | }} 188 | {%- endif %} 189 | 190 | {#- Forbidden crawlers router #} 191 | {%- if paths_without_crawlers and not domain_group.path_prefixes %} 192 | {{- 193 | router( 194 | domain_group=domain_group, 195 | key=key, 196 | suffix="forbiddenCrawlers", 197 | rule="%s && %s" % ( 198 | domains_rule(domain_group), 199 | path_prefix_rule(paths_without_crawlers), 200 | ), 201 | middlewares=_ns.basic_middlewares + [ 202 | "buffering", 203 | "compress", 204 | "forbid-crawlers", 205 | ], 206 | priority=10, 207 | ) 208 | }} 209 | {%- endif %} 210 | {%- if paths_with_crawlers and not domain_group.path_prefixes %} 211 | {{- 212 | router( 213 | domain_group=domain_group, 214 | key=key, 215 | suffix="allowedCrawlers", 216 | rule="%s && %s" % ( 217 | domains_rule(domain_group), 218 | path_prefix_rule(paths_with_crawlers), 219 | ), 220 | middlewares=_ns.basic_middlewares + [ 221 | "buffering", 222 | "compress", 223 | ], 224 | priority=100, 225 | ) 226 | }} 227 | {%- endif %} 228 | {%- endif %} 229 | {%- endcall %} 230 | {%- endmacro %} 231 | 232 | {#- Basic labels for a single router #} 233 | {%- macro router_tcp(domain_group, key, suffix, rule=none, service=none, middlewares=(), port=none) %} 234 | {%- if port %} 235 | traefik.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.port: {{ port }} 236 | {%- endif %} 237 | traefik.tcp.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.rule: 238 | {{ rule|default(domains_rule(domain_group), true) }} 239 | traefik.tcp.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.service: 240 | {{ key }}-{{ service|default("main", true) }} 241 | {%- if domain_group.entrypoints %} 242 | traefik.tcp.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.entrypoints: 243 | {{ domain_group.entrypoints|sort|join(", ") }} 244 | {%- endif %} 245 | {%- if middlewares %} 246 | traefik.tcp.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.middlewares: 247 | {{ key }}-{{ middlewares|sort|join(", %s-" % key) }} 248 | {%- endif %} 249 | {%- endmacro %} 250 | 251 | {%- macro database(domain_groups_list, cidr_whitelist, key, port, project_name) %} 252 | {#- Service #} 253 | traefik.tcp.services.{{ key }}-database.loadbalancer.server.port: 5432 254 | 255 | {%- if cidr_whitelist %} 256 | {#- Declare whitelist middleware #} 257 | ? traefik.tcp.middlewares.{{ key }}-whitelist.IPWhiteList.sourceRange 258 | : {% for cidr in cidr_whitelist -%} 259 | {{ cidr }}{% if not loop.last %}, {% endif %} 260 | {%- endfor %} 261 | {%- endif %} 262 | 263 | {%- call(domain_group) macros.domains_loop_grouped(domain_groups_list) %} 264 | {#- Remember basic middlewares for this domain group #} 265 | {%- set _ns = namespace(basic_middlewares=[]) -%} 266 | {%- if cidr_whitelist %} 267 | {%- set _ns.basic_middlewares = _ns.basic_middlewares + ["whitelist"] %} 268 | {%- endif %} 269 | 270 | {#- database router #} 271 | {{- 272 | router_tcp( 273 | domain_group=domain_group, 274 | key=key, 275 | suffix="database", 276 | service="database", 277 | middlewares=_ns.basic_middlewares, 278 | port=port, 279 | ) 280 | }} 281 | {%- endcall %} 282 | {%- endmacro %} 283 | -------------------------------------------------------------------------------- /_traefik3_paths_labels.yml.jinja: -------------------------------------------------------------------------------- 1 | {%- import "_macros.jinja" as macros -%} 2 | {# 3 | Note: indentation of 6 spaces is important because that's the depth level 4 | of container labels in a docker-compose file. 5 | #} 6 | 7 | {# Echo all path prefixes in a Traefik rule #} 8 | {%- macro path_prefix_rule(path_prefixes) %} 9 | {%- set _ns = namespace(with_slash=[], without_slash=[]) %} 10 | {%- for prefix in path_prefixes %} 11 | {%- if prefix.endswith("/") %} 12 | {%- set _ns.with_slash = _ns.with_slash + [prefix] %} 13 | {%- else %} 14 | {%- set _ns.with_slash = _ns.with_slash + ["%s/" % prefix] %} 15 | {%- set _ns.without_slash = _ns.without_slash + [prefix] %} 16 | {%- endif %} 17 | {%- endfor -%} 18 | (PathPrefix( 19 | {%- for path in _ns.with_slash -%} 20 | `{{ path }}` 21 | {%- if not loop.last %}) || PathPrefix({% endif %} 22 | {%- endfor -%} 23 | ) 24 | {%- if _ns.without_slash %} || Path( 25 | {%- for path in _ns.without_slash -%} 26 | `{{ path }}` 27 | {%- if not loop.last %}) || Path({% endif %} 28 | {%- endfor -%} 29 | ) 30 | {%- endif -%} 31 | ) 32 | {%- endmacro %} 33 | 34 | {# Echo all domains of a group, in a Traefik rule #} 35 | {%- macro domains_rule(domain_group) -%} 36 | {%- if domain_group.hosts | length == 1 -%} 37 | Host(`{{ domain_group.hosts[0] }}`) 38 | {%- else -%} 39 | (Host(`{{ domain_group.hosts | join('`) || Host(`') }}`)) 40 | {%- endif -%} 41 | {%- if domain_group.path_prefixes %} && {{ path_prefix_rule(domain_group.path_prefixes) }} 42 | {%- endif %} 43 | {%- endmacro %} 44 | 45 | {%- macro domains_rule_sni(hosts) -%} 46 | {%- for host in hosts -%} 47 | HostSNI(`{{ host }}`) 48 | {%- if not loop.last -%} 49 | || 50 | {%- endif -%} 51 | {%- endfor -%} 52 | {%- endmacro %} 53 | 54 | 55 | {%- macro key(project_name, odoo_version, suffix) %} 56 | {{- '%s-%.1f-%s'|format(project_name, odoo_version, suffix)|replace('.', '-') }} 57 | {%- endmacro %} 58 | 59 | {#- Basic labels for a single router #} 60 | {%- macro router(domain_group, key, suffix, rule=none, service=none, middlewares=(), priority=none) %} 61 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.rule: 62 | {{ rule|default(domains_rule(domain_group), true) }} 63 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.service: 64 | {{ key }}-{{ service|default("main", true) }} 65 | {%- if domain_group.entrypoints %} 66 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.entrypoints: 67 | {{ domain_group.entrypoints|sort|join(", ") }} 68 | {%- endif %} 69 | {%- if middlewares %} 70 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.middlewares: 71 | {{ key }}-{{ middlewares|sort|join(", %s-" % key) }} 72 | {%- endif %} 73 | {%- if priority %} 74 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.priority: {{ priority }} 75 | {%- endif %} 76 | 77 | {%- if domain_group.cert_resolver %} 78 | 79 | {#- Add TLS configuration #} 80 | {%- if suffix.endswith("-secure") %} 81 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.tls: "true" 82 | {%- if domain_group.cert_resolver is string %} 83 | traefik.http.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.tls.certResolver: 84 | {{ domain_group.cert_resolver }} 85 | {%- endif %} 86 | 87 | {#- Create TLS-only router; 88 | HACK https://github.com/containous/traefik/issues/7235 #} 89 | {%- else %} 90 | {{- router(domain_group, key, "%s-secure" % suffix, rule, service, middlewares, priority) }} 91 | {%- endif %} 92 | 93 | {%- endif %} 94 | {%- endmacro %} 95 | 96 | {%- macro custom_ws_middleware(key) -%} 97 | {#- Force X-Forwarded-Proto to https for websockets #} 98 | traefik.http.middlewares.{{ key }}-override-ws-headers.headers.customRequestHeaders.X-Forwarded-Proto: "https" 99 | {%- endmacro %} 100 | 101 | {%- macro common_middlewares(key, cidr_whitelist) %} 102 | {#- Common middlewares #} 103 | traefik.http.middlewares.{{ key }}-buffering.buffering.retryExpression: 104 | IsNetworkError() && Attempts() < 5 105 | traefik.http.middlewares.{{ key }}-compress.compress: "true" 106 | ? traefik.http.middlewares.{{ key }}-forbid-crawlers.headers.customResponseHeaders.X-Robots-Tag 107 | : "noindex, nofollow" 108 | traefik.http.middlewares.{{ key }}-addSTS.headers.forceSTSHeader: "true" 109 | traefik.http.middlewares.{{ key }}-forceSecure.redirectScheme.scheme: https 110 | traefik.http.middlewares.{{ key }}-forceSecure.redirectScheme.permanent: "true" 111 | {%- if cidr_whitelist %} 112 | {#- Declare whitelist middleware #} 113 | ? traefik.http.middlewares.{{ key }}-whitelist.IPWhiteList.sourceRange 114 | : {% for cidr in cidr_whitelist -%} 115 | {{ cidr }}{% if not loop.last %}, {% endif %} 116 | {%- endfor %} 117 | {%- endif %} 118 | {%- endmacro %} 119 | 120 | {%- macro odoo(domain_groups_list, cidr_whitelist, key, odoo_version, 121 | paths_without_crawlers, paths_with_crawlers, project_name) %} 122 | {%- if odoo_version >= 16%} 123 | {{- custom_ws_middleware(key) }} 124 | {%- endif %} 125 | {#- Service #} 126 | traefik.http.services.{{ key }}-main.loadbalancer.server.port: 8069 127 | traefik.http.services.{{ key }}-longpolling.loadbalancer.server.port: 8072 128 | {%- call(domain_group) macros.domains_loop_grouped(domain_groups_list) %} 129 | {#- Remember basic middlewares for this domain group #} 130 | {%- set _ns = namespace(basic_middlewares=[]) -%} 131 | {%- if cidr_whitelist %} 132 | {%- set _ns.basic_middlewares = _ns.basic_middlewares + ["whitelist"] %} 133 | {%- endif %} 134 | {%- if domain_group.cert_resolver %} 135 | {%- set _ns.basic_middlewares = _ns.basic_middlewares + ["addSTS", "forceSecure"] %} 136 | {%- endif %} 137 | 138 | {%- if domain_group.redirect_to %} 139 | {#- Redirections #} 140 | {%- if domain_group.redirect_permanent %} 141 | traefik.http.middlewares.{{ key }}-redirect-{{ domain_group.loop.index0 }}.redirectRegex.permanent: "true" 142 | {%- endif %} 143 | traefik.http.middlewares.{{ key }}-redirect-{{ domain_group.loop.index0 }}.redirectRegex.regex: ^(.*)://([^/]+)/(.*)$$ 144 | traefik.http.middlewares.{{ key }}-redirect-{{ domain_group.loop.index0 }}.redirectRegex.replacement: $$1://{{ domain_group.redirect_to }}/$$3 145 | {{- 146 | router( 147 | domain_group=domain_group, 148 | key=key, 149 | suffix="redirect", 150 | middlewares=_ns.basic_middlewares + [ 151 | "compress", 152 | "redirect-%d" % domain_group.loop.index0, 153 | ], 154 | priority=10, 155 | ) 156 | }} 157 | {%- else %} 158 | 159 | {#- When removing crawlers for /, this router is the same as forbiddenCrawlers, so no need to duplicate #} 160 | {%- if paths_without_crawlers != ["/"] or domain_group.path_prefixes %} 161 | {#- Main router #} 162 | {{- 163 | router( 164 | domain_group=domain_group, 165 | key=key, 166 | suffix="main", 167 | middlewares=_ns.basic_middlewares + [ 168 | "buffering", 169 | "compress", 170 | ], 171 | priority=1, 172 | ) 173 | }} 174 | {%- endif %} 175 | 176 | {#- Longpolling router #} 177 | {%- if not domain_group.path_prefixes %} 178 | {%- set longpolling_route = "PathPrefix(`/longpolling/`)" if odoo_version < 16 else "Path(`/websocket`)" -%} 179 | {%- if odoo_version >= 16 %} 180 | {%- set ws_middlewares = _ns.basic_middlewares + ["override-ws-headers"] %} 181 | {%- else %} 182 | {%- set ws_middlewares = _ns.basic_middlewares %} 183 | {%- endif %} 184 | {{- 185 | router( 186 | domain_group=domain_group, 187 | key=key, 188 | suffix="longpolling", 189 | rule="%s && %s" % (domains_rule(domain_group), longpolling_route), 190 | service="longpolling", 191 | middlewares=ws_middlewares, 192 | priority=20, 193 | ) 194 | }} 195 | {%- endif %} 196 | 197 | {#- Forbidden crawlers router #} 198 | {%- if paths_without_crawlers and not domain_group.path_prefixes %} 199 | {{- 200 | router( 201 | domain_group=domain_group, 202 | key=key, 203 | suffix="forbiddenCrawlers", 204 | rule="%s && %s" % ( 205 | domains_rule(domain_group), 206 | path_prefix_rule(paths_without_crawlers), 207 | ), 208 | middlewares=_ns.basic_middlewares + [ 209 | "buffering", 210 | "compress", 211 | "forbid-crawlers", 212 | ], 213 | priority=10, 214 | ) 215 | }} 216 | {%- endif %} 217 | 218 | {#- Allowed crawlers router #} 219 | {%- if paths_with_crawlers and not domain_group.path_prefixes %} 220 | {{- 221 | router( 222 | domain_group=domain_group, 223 | key=key, 224 | suffix="allowedCrawlers", 225 | rule="%s && %s" % ( 226 | domains_rule(domain_group), 227 | path_prefix_rule(paths_with_crawlers), 228 | ), 229 | middlewares=_ns.basic_middlewares + [ 230 | "buffering", 231 | "compress", 232 | ], 233 | priority=100, 234 | ) 235 | }} 236 | {%- endif %} 237 | {%- endif %} 238 | {%- endcall %} 239 | {%- endmacro %} 240 | 241 | {#- Basic labels for a single router #} 242 | {%- macro router_tcp(domain_group, key, suffix, rule=none, service=none, middlewares=(), port=none) %} 243 | {%- if port %} 244 | traefik.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.port: {{ port }} 245 | {%- endif %} 246 | traefik.tcp.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.rule: 247 | {{ rule|default(domains_rule(domain_group), true) }} 248 | traefik.tcp.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.service: 249 | {{ key }}-{{ service|default("main", true) }} 250 | {%- if domain_group.entrypoints %} 251 | traefik.tcp.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.entrypoints: 252 | {{ domain_group.entrypoints|sort|join(", ") }} 253 | {%- endif %} 254 | {%- if middlewares %} 255 | traefik.tcp.routers.{{ key }}-{{ suffix }}-{{ domain_group.loop.index0 }}.middlewares: 256 | {{ key }}-{{ middlewares|sort|join(", %s-" % key) }} 257 | {%- endif %} 258 | {%- endmacro %} 259 | 260 | {%- macro database(domain_groups_list, cidr_whitelist, key, port, project_name) %} 261 | {#- Service #} 262 | traefik.tcp.routers.{{ key }}-database.entrypoints: postgres-entrypoint 263 | traefik.tcp.services.{{ key }}-database.loadbalancer.server.port: 5432 264 | traefik.tcp.routers.{{ key }}-database.tls: "true" 265 | traefik.tcp.routers.{{ key }}-database.tls.certResolver: letsencrypt 266 | 267 | {%- if cidr_whitelist %} 268 | {#- Declare whitelist middleware #} 269 | ? traefik.tcp.middlewares.{{ key }}-whitelist.IPWhiteList.sourceRange 270 | : {% for cidr in cidr_whitelist -%} 271 | {{ cidr }}{% if not loop.last %}, {% endif %} 272 | {%- endfor %} 273 | {%- endif %} 274 | 275 | {%- set ns_hosts = namespace(all_hosts=[]) %} 276 | {%- for domain_group in domain_groups_list %} 277 | {%- set ns_hosts.all_hosts = ns_hosts.all_hosts + domain_group.hosts %} 278 | {%- endfor %} 279 | traefik.tcp.routers.{{ key }}-database.rule: {{ domains_rule_sni(ns_hosts.all_hosts) }} 280 | 281 | {#- Remember basic middlewares for this domain group #} 282 | {%- set _ns = namespace(basic_middlewares=[]) -%} 283 | {%- if cidr_whitelist %} 284 | {%- set _ns.basic_middlewares = _ns.basic_middlewares + ["whitelist"] %} 285 | {%- endif %} 286 | {%- endmacro %} 287 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | import socket 5 | import stat 6 | import tempfile 7 | import textwrap 8 | from contextlib import contextmanager 9 | from pathlib import Path 10 | from typing import Dict, Union 11 | 12 | import pytest 13 | import yaml 14 | from plumbum import ProcessExecutionError, local 15 | from plumbum.cmd import git, invoke 16 | from python_on_whales import DockerClient 17 | 18 | _logger = logging.getLogger(__name__) 19 | 20 | with open("copier.yml") as copier_fd: 21 | COPIER_SETTINGS = yaml.safe_load(copier_fd) 22 | 23 | # Different tests test different Odoo versions 24 | OLDEST_SUPPORTED_ODOO_VERSION = 11.0 25 | ALL_ODOO_VERSIONS = tuple(COPIER_SETTINGS["odoo_version"]["choices"]) 26 | TRAEFIK_VERSION = os.getenv("TRAEFIK_VERSION", "3") 27 | 28 | SUPPORTED_ODOO_VERSIONS = tuple( 29 | v for v in ALL_ODOO_VERSIONS if v >= OLDEST_SUPPORTED_ODOO_VERSION 30 | ) 31 | LAST_ODOO_VERSION = max(SUPPORTED_ODOO_VERSIONS) 32 | SELECTED_ODOO_VERSIONS = ( 33 | frozenset(map(float, os.environ.get("SELECTED_ODOO_VERSIONS", "").split())) 34 | or ALL_ODOO_VERSIONS 35 | ) 36 | PRERELEASE_ODOO_VERSIONS = {19.0} 37 | 38 | # Postgres versions 39 | ALL_PSQL_VERSIONS = tuple(COPIER_SETTINGS["postgres_version"]["choices"]) 40 | LATEST_PSQL_VER = ALL_PSQL_VERSIONS[-1] 41 | DBVER_PER_ODOO = { 42 | 11.0: { 43 | "oldest": "10", # Odoo supports 9.6, but that version is not supported by the backup service and is necessary to be able to perform all tests 44 | "latest": "13", # DB Authentication method limitation 45 | }, 46 | 12.0: { 47 | "oldest": "10", # Odoo supports 9.6, but that version is not supported by the backup service and is necessary to be able to perform all tests 48 | "latest": "13", 49 | }, 50 | 13.0: { 51 | "oldest": "10", # Odoo supports 9.6, but that version is not supported by the backup service and is necessary to be able to perform all tests 52 | "latest": "16", 53 | }, 54 | 14.0: { 55 | "oldest": "10", 56 | "latest": "16", 57 | }, 58 | 15.0: { 59 | "oldest": "10", 60 | "latest": LATEST_PSQL_VER, 61 | }, 62 | 16.0: { 63 | "oldest": "12", 64 | "latest": LATEST_PSQL_VER, 65 | }, 66 | 17.0: { 67 | "oldest": "12", 68 | "latest": LATEST_PSQL_VER, 69 | }, 70 | 18.0: { 71 | "oldest": "12", 72 | "latest": LATEST_PSQL_VER, 73 | }, 74 | 19.0: { 75 | "oldest": "12", 76 | "latest": LATEST_PSQL_VER, 77 | }, 78 | } 79 | 80 | 81 | @pytest.fixture(autouse=True) 82 | def skip_odoo_prereleases(supported_odoo_version: float, request): 83 | """Fixture to automatically skip tests for prereleased odoo versions.""" 84 | if ( 85 | request.node.get_closest_marker("skip_for_prereleases") 86 | and supported_odoo_version in PRERELEASE_ODOO_VERSIONS 87 | ): 88 | pytest.skip( 89 | f"skipping tests for prereleased odoo version {supported_odoo_version}" 90 | ) 91 | 92 | 93 | def pytest_addoption(parser): 94 | parser.addoption( 95 | "--skip-docker-tests", 96 | action="store_true", 97 | default=False, 98 | help="Skip Docker tests", 99 | ) 100 | 101 | 102 | @pytest.fixture(params=ALL_ODOO_VERSIONS) 103 | def any_odoo_version(request) -> float: 104 | """Returns any usable odoo version.""" 105 | if request.param not in SELECTED_ODOO_VERSIONS: 106 | pytest.skip("odoo version not in selected range") 107 | return request.param 108 | 109 | 110 | @pytest.fixture(params=SUPPORTED_ODOO_VERSIONS) 111 | def supported_odoo_version(request) -> float: 112 | """Returns any usable odoo version.""" 113 | if request.param not in SELECTED_ODOO_VERSIONS: 114 | pytest.skip("supported odoo version not in selected range") 115 | return request.param 116 | 117 | 118 | @pytest.fixture() 119 | def cloned_template(): 120 | """This repo cloned to a temporary destination. 121 | 122 | The clone will include dirty changes, and it will have a 'test' tag in its HEAD. 123 | 124 | It returns the local `Path` to the clone. 125 | """ 126 | patches = [git("diff", "--cached"), git("diff")] 127 | with tempfile.TemporaryDirectory("_cloned_template") as dirty_template_clone: 128 | git("clone", ".", dirty_template_clone) 129 | with local.cwd(dirty_template_clone): 130 | git("config", "commit.gpgsign", "false") 131 | for patch in patches: 132 | if patch: 133 | (git["apply", "--reject"] << patch)() 134 | git("add", ".") 135 | git( 136 | "commit", 137 | "--author=Test", 138 | "--message=dirty changes", 139 | "--no-verify", 140 | ) 141 | git("tag", "--force", "test") 142 | yield dirty_template_clone 143 | 144 | 145 | @pytest.fixture() 146 | def versionless_odoo_autoskip(request): 147 | """Fixture to automatically skip tests when testing for older odoo versions.""" 148 | is_version_specific_test = ( 149 | "any_odoo_version" in request.fixturenames 150 | or "supported_odoo_version" in request.fixturenames 151 | ) 152 | if LAST_ODOO_VERSION not in SELECTED_ODOO_VERSIONS and not is_version_specific_test: 153 | pytest.skip("version-independent test in old versioned odoo test session") 154 | 155 | 156 | @pytest.fixture(params=TRAEFIK_VERSION) 157 | def traefik_host(request): 158 | """Fixture to indicate where to find a running traefik instance.""" 159 | docker = DockerClient() 160 | if request.param == "3": 161 | traefik_container = docker.run( 162 | "traefik:v3.1.2", 163 | detach=True, 164 | privileged=True, 165 | networks=["inverseproxy_shared"], 166 | volumes=[("/var/run/docker.sock", "/var/run/docker.sock", "ro")], 167 | command=[ 168 | "--accessLog=true", 169 | "--entryPoints.web-alt.address=:8080", 170 | "--entryPoints.web-insecure.address=:80", 171 | "--entryPoints.web-main.address=:443", 172 | "--log.level=debug", 173 | "--providers.docker.exposedByDefault=false", 174 | "--providers.docker.network=inverseproxy_shared", 175 | "--providers.docker=true", 176 | ], 177 | ) 178 | elif request.param == "2": 179 | traefik_container = docker.run( 180 | "traefik:v2.4", 181 | detach=True, 182 | privileged=True, 183 | networks=["inverseproxy_shared"], 184 | volumes=[("/var/run/docker.sock", "/var/run/docker.sock", "ro")], 185 | command=[ 186 | "--accessLog=true", 187 | "--entrypoints.web-alt.address=:8080", 188 | "--entrypoints.web-insecure.address=:80", 189 | "--entrypoints.web-main.address=:443", 190 | "--log.level=debug", 191 | "--providers.docker.exposedByDefault=false", 192 | "--providers.docker.network=inverseproxy_shared", 193 | "--providers.docker=true", 194 | ], 195 | ) 196 | else: 197 | traefik_container = docker.run( 198 | "traefik:v1.7", 199 | detach=True, 200 | privileged=True, 201 | networks=["inverseproxy_shared"], 202 | volumes=[("/var/run/docker.sock", "/var/run/docker.sock", "ro")], 203 | command=[ 204 | "--defaultEntryPoints=web-insecure,web-main", 205 | "--docker.exposedByDefault=false", 206 | "--docker.watch", 207 | "--docker", 208 | "--entryPoints=Name:web-alt Address::8080 Compress:on", 209 | "--entryPoints=Name:web-insecure Address::80 Redirect.EntryPoint:web-main", 210 | "--entryPoints=Name:web-main Address::443 Compress:on TLS TLS.minVersion:VersionTLS12", 211 | "--logLevel=debug", 212 | ], 213 | ) 214 | interesting_details = { 215 | "ip": traefik_container.network_settings.networks[ 216 | "inverseproxy_shared" 217 | ].ip_address, 218 | "traefik_version": traefik_container.config.labels[ 219 | "org.opencontainers.image.version" 220 | ], 221 | "traefik_image": traefik_container.config.image, 222 | } 223 | interesting_details["hostname"] = f"{interesting_details['ip']}.sslip.io" 224 | yield interesting_details 225 | # Make sure there were no errors or warnings in logs 226 | traefik_logs = docker.logs(traefik_container) 227 | docker.remove(traefik_container, force=True) 228 | assert " level=error " not in traefik_logs 229 | assert " level=warn " not in traefik_logs 230 | 231 | 232 | def teardown_function(function): 233 | pre_commit_log = ( 234 | Path("~") / ".cache" / "pre-commit" / "pre-commit.log" 235 | ).expanduser() 236 | if pre_commit_log.is_file(): 237 | print(pre_commit_log.read_text()) 238 | pre_commit_log.unlink() 239 | 240 | 241 | # Helpers 242 | def build_file_tree(spec: Dict[Union[str, Path], str], dedent: bool = True): 243 | """Builds a file tree based on the received spec.""" 244 | for path, contents in spec.items(): 245 | path = Path(path) 246 | if dedent: 247 | contents = textwrap.dedent(contents) 248 | path.parent.mkdir(parents=True, exist_ok=True) 249 | with path.open("w") as fd: 250 | fd.write(contents) 251 | 252 | 253 | def socket_is_open(host, port): 254 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 255 | if sock.connect_ex((host, port)) == 0: 256 | return True 257 | return False 258 | 259 | 260 | def generate_test_addon( 261 | addon_name, odoo_version, installable=True, ugly=False, dependencies=None 262 | ): 263 | """Generates a simple addon for testing 264 | Can be an ugly addon to trigger pre-commit formatting 265 | """ 266 | is_py3 = odoo_version >= 11 267 | manifest = "__manifest__" if is_py3 else "__openerp__" 268 | file_tree = { 269 | f"{addon_name}/__init__.py": """\ 270 | from . import models 271 | """, 272 | f"{addon_name}/models/__init__.py": """\ 273 | from . import res_partner 274 | """, 275 | } 276 | if ugly: 277 | file_tree.update( 278 | { 279 | f"{addon_name}/{manifest}.py": f"""\ 280 | {"{"} 281 | 'name':"{addon_name}",'license':'AGPL-3', 282 | 'version':'{odoo_version}.1.0.0', 283 | 'depends': {dependencies or '["base"]'}, 284 | 'installable': {installable}, 285 | 'auto_install': False, 286 | 'author': 'Tecnativa', 287 | {"}"} 288 | """, 289 | f"{addon_name}/models/res_partner.py": """\ 290 | from odoo import models;from os.path import join; 291 | from requests import get 292 | from logging import getLogger 293 | import io,sys,odoo 294 | _logger=getLogger(__name__) 295 | class ResPartner(models.Model): 296 | _inherit='res.partner' 297 | def some_method(self,test): 298 | '''some weird 299 | docstring''' 300 | _logger.info(models,join,get,io,sys,odoo) 301 | """, 302 | f"{addon_name}/README.rst": "", 303 | f"{addon_name}/readme/DESCRIPTION.rst": addon_name, 304 | } 305 | ) 306 | else: 307 | file_tree.update( 308 | { 309 | f"{addon_name}/{manifest}.py": f"""\ 310 | {"{"} 311 | "name": "{addon_name}", 312 | "license": "AGPL-3", 313 | "version": "{odoo_version}.1.0.0", 314 | "depends": {dependencies or '["base"]'}, 315 | "installable": {installable}, 316 | "auto_install": False, 317 | "author": "Tecnativa", 318 | {"}"} 319 | """, 320 | f"{addon_name}/models/res_partner.py": '''\ 321 | import io 322 | import sys 323 | from logging import getLogger 324 | from os.path import join 325 | 326 | from requests import get 327 | 328 | import odoo 329 | from odoo import models 330 | 331 | _logger = getLogger(__name__) 332 | 333 | 334 | class ResPartner(models.Model): 335 | _inherit = "res.partner" 336 | 337 | def some_method(self, test): 338 | """some weird 339 | docstring""" 340 | _logger.info(models, join, get, io, sys, odoo) 341 | ''', 342 | } 343 | ) 344 | build_file_tree(file_tree) 345 | 346 | 347 | def _containers_running(exec_path): 348 | with local.cwd(exec_path): 349 | docker = DockerClient() 350 | containers_list = docker.container.list(all=True) 351 | if len(containers_list) > 0: 352 | _logger.error(containers_list) 353 | return True 354 | return False 355 | 356 | 357 | def safe_stop_env(exec_path, purge=True): 358 | with local.cwd(exec_path): 359 | try: 360 | args = ["stop"] 361 | if purge: 362 | args.append("--purge") 363 | invoke.run(args) 364 | except ProcessExecutionError as e: 365 | if ( 366 | "has active endpoints" not in e.stderr 367 | and "has active endpoints" not in e.stdout 368 | ): 369 | raise e 370 | assert not _containers_running( 371 | exec_path 372 | ), "Containers running or not removed. 'stop [--purge]' command did not work." 373 | 374 | 375 | @contextmanager 376 | def bypass_pre_commit(): 377 | """A context manager to patch the pre-commit binary to ignore it""" 378 | pre_commit_path_str = shutil.which("pre-commit") 379 | try: 380 | # Move current binary to different location 381 | pre_commit_path = Path(pre_commit_path_str) 382 | shutil.move(pre_commit_path_str, pre_commit_path_str + "-old") 383 | with pre_commit_path.open("w") as fd: 384 | fd.write( 385 | "#!/usr/bin/python3\n" 386 | "# -*- coding: utf-8 -*-\n" 387 | "import sys\n" 388 | "if __name__ == '__main__':\n" 389 | " sys.exit(0)\n" 390 | ) 391 | cur_stat = pre_commit_path.stat() 392 | # Like chmod ug+x 393 | pre_commit_path.chmod(cur_stat.st_mode | stat.S_IXUSR | stat.S_IXGRP) 394 | yield 395 | finally: 396 | # Restore original binary 397 | shutil.move(pre_commit_path_str + "-old", pre_commit_path_str) 398 | -------------------------------------------------------------------------------- /tests/test_tasks_downstream.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | from pathlib import Path 4 | 5 | import pytest 6 | from copier import run_copy 7 | from plumbum import ProcessExecutionError, local 8 | from plumbum.cmd import invoke 9 | from python_on_whales import DockerClient 10 | from python_on_whales.exceptions import DockerException 11 | 12 | from .conftest import ( 13 | DBVER_PER_ODOO, 14 | build_file_tree, 15 | generate_test_addon, 16 | safe_stop_env, 17 | socket_is_open, 18 | ) 19 | 20 | 21 | def _install_status(module): 22 | docker = DockerClient(compose_files=["devel.yaml"]) 23 | return docker.compose.run( 24 | "odoo", 25 | command=[ 26 | "psql", 27 | "-tc", 28 | f"select state from ir_module_module where name='{module}'", 29 | ], 30 | # envs={ 31 | # 'LOG_LEVEL': 'WARNING', 32 | # 'PGDATABASE': dbname, 33 | # }, 34 | remove=True, 35 | tty=False, 36 | ).strip() 37 | 38 | 39 | def _get_config_param(key): 40 | docker = DockerClient(compose_files=["devel.yaml"]) 41 | return docker.compose.run( 42 | "odoo", 43 | command=[ 44 | "psql", 45 | "-tc", 46 | f"select value from ir_config_parameter where key='{key}'", 47 | ], 48 | # envs={ 49 | # 'LOG_LEVEL': 'WARNING', 50 | # 'PGDATABASE': dbname, 51 | # }, 52 | remove=True, 53 | tty=False, 54 | ).strip() 55 | 56 | 57 | def _wait_for_test_to_start(): 58 | docker = DockerClient(compose_files=["devel.yaml"]) 59 | # Wait for test to start 60 | for _i in range(10): 61 | time.sleep(2) 62 | stdout = docker.compose.logs("odoo") 63 | if "Executing odoo --test-enable" in stdout: 64 | break 65 | return stdout 66 | 67 | 68 | def _tests_ran(stdout, odoo_version, addon_name): 69 | # Ensure the addon was installed/updated, and not independent ones 70 | if odoo_version < 19: 71 | assert f"module {addon_name}: creating or updating database tables" in stdout 72 | # Ensure the addon was tested 73 | main_pkg, suffix = "odoo", r"\:\sStarting" 74 | if odoo_version < 13.0: 75 | suffix = r"\srunning tests." 76 | if odoo_version < 10.0: 77 | main_pkg = "openerp" 78 | assert re.search( 79 | rf"{main_pkg}\.addons\.{addon_name}\.tests\.\w+{suffix}", stdout 80 | ) 81 | # Check no alien addons are installed, updated or tested 82 | if addon_name != "base": 83 | assert "module base: creating or updating database tables" not in stdout 84 | assert not re.search( 85 | rf"{main_pkg}\.addons\.base\.tests\.\w+{suffix}", stdout 86 | ) 87 | else: 88 | assert any( 89 | mark in stdout 90 | for mark in ( 91 | f"Installing module {addon_name}", 92 | f"odoo.addons.{addon_name}", 93 | "Running tests", 94 | "tests succeeded", 95 | "All tests passed", 96 | "odoo.sql_db: ConnectionPool", 97 | ) 98 | ) 99 | 100 | 101 | @pytest.mark.sequential 102 | def test_resetdb( 103 | cloned_template: Path, 104 | supported_odoo_version: float, 105 | tmp_path: Path, 106 | ): 107 | """Test the dropdb task. 108 | 109 | On this test flow, other downsream tasks are also tested: 110 | 111 | - img-build 112 | - git-aggregate 113 | - stop --purge 114 | - snapshot 115 | - restore-snapshot 116 | """ 117 | try: 118 | with local.cwd(tmp_path): 119 | data = { 120 | "odoo_version": supported_odoo_version, 121 | "postgres_version": DBVER_PER_ODOO[supported_odoo_version]["latest"], 122 | "postgres_dbname": "devel", 123 | } 124 | run_copy( 125 | src_path=str(cloned_template), 126 | data=data, 127 | vcs_ref="HEAD", 128 | defaults=True, 129 | overwrite=True, 130 | unsafe=True, 131 | ) 132 | # Imagine the user is in the src subfolder for these tasks 133 | with local.cwd(tmp_path / "odoo" / "custom" / "src"): 134 | invoke("img-build") 135 | invoke("git-aggregate") 136 | # No ir_module_module table exists yet 137 | with pytest.raises(DockerException): 138 | _install_status("base") 139 | # Imagine the user is in the odoo subrepo for these tasks 140 | with local.cwd(tmp_path / "odoo" / "custom" / "src" / "odoo"): 141 | # This should install just "base" 142 | stdout = invoke("resetdb", "--no-populate") 143 | if supported_odoo_version < 19: 144 | assert "Creating database cache" in stdout 145 | assert "from template devel" in stdout 146 | else: 147 | assert "odoo.sql_db: ConnectionPool" in stdout 148 | assert _install_status("base") == "installed" 149 | assert _install_status("purchase") == "uninstalled" 150 | assert _install_status("sale") == "uninstalled" 151 | assert not _get_config_param("report.url") 152 | # Install "purchase" 153 | stdout = invoke("resetdb", "-m", "purchase") 154 | if supported_odoo_version < 19: 155 | assert "Creating database cache" in stdout 156 | assert "from template devel" in stdout 157 | else: 158 | assert "odoo.sql_db: ConnectionPool" in stdout 159 | assert _install_status("base") == "installed" 160 | assert _install_status("purchase") == "installed" 161 | assert _install_status("sale") == "uninstalled" 162 | # Install "sale" in a separate database 163 | stdout = invoke("resetdb", "-m", "sale", "-d", "sale_only") 164 | if supported_odoo_version < 19: 165 | assert "Creating database cache" in stdout 166 | assert "from template sale_only" in stdout 167 | else: 168 | assert "odoo.sql_db: ConnectionPool" in stdout 169 | assert _install_status("base") == "installed" 170 | assert _install_status("purchase") == "installed" 171 | assert _install_status("sale") == "uninstalled" 172 | # FIXME: See https://github.com/gabrieldemarmiesse/python-on-whales/pull/380 173 | # assert _install_status("base", "sale_only") == "installed" 174 | # assert _install_status("purchase", "sale_only") == "uninstalled" 175 | # assert _install_status("sale", "sale_only") == "installed" 176 | # Install "sale" in main database 177 | stdout = invoke("resetdb", "-m", "sale") 178 | if supported_odoo_version < 19: 179 | assert "Creating database devel from template cache" in stdout 180 | assert "Found matching database template" in stdout 181 | else: 182 | assert "odoo.sql_db: ConnectionPool" in stdout 183 | assert _install_status("base") == "installed" 184 | assert _install_status("purchase") == "uninstalled" 185 | assert _install_status("sale") == "installed" 186 | # Snapshot current DB 187 | invoke("snapshot", "--destination-db", "db_with_sale") 188 | if supported_odoo_version >= 11: 189 | invoke("preparedb") 190 | assert _get_config_param("report.url") == "http://localhost:8069" 191 | stdout = invoke("resetdb") # --populate default 192 | # report.url should be set in the DB 193 | assert _get_config_param("report.url") == "http://localhost:8069" 194 | else: 195 | invoke( 196 | "resetdb" 197 | ) # Despite new default --populate, shouldn't introduce error 198 | with pytest.raises(ProcessExecutionError): 199 | invoke("preparedb") 200 | # DB should now be reset 201 | assert _install_status("sale") == "uninstalled" 202 | # Restore snapshot 203 | invoke("restore-snapshot", "--snapshot-name", "db_with_sale") 204 | assert _install_status("sale") == "installed" 205 | finally: 206 | safe_stop_env(tmp_path / "odoo" / "custom" / "src" / "odoo") 207 | 208 | 209 | @pytest.mark.sequential 210 | def test_start( 211 | cloned_template: Path, 212 | supported_odoo_version: float, 213 | tmp_path: Path, 214 | ): 215 | """Test the start task. 216 | 217 | On this test flow, other downsream tasks are also tested: 218 | 219 | - img-build 220 | - git-aggregate 221 | - stop --purge 222 | """ 223 | try: 224 | with local.cwd(tmp_path): 225 | data = { 226 | "odoo_version": supported_odoo_version, 227 | "postgres_version": DBVER_PER_ODOO[supported_odoo_version]["latest"], 228 | "postgres_dbname": "devel", 229 | } 230 | run_copy( 231 | src_path=str(cloned_template), 232 | data=data, 233 | vcs_ref="HEAD", 234 | defaults=True, 235 | overwrite=True, 236 | unsafe=True, 237 | ) 238 | # Imagine the user is in the src subfolder for these tasks 239 | with local.cwd(tmp_path / "odoo" / "custom" / "src"): 240 | invoke("img-build") 241 | stdout = invoke("git-aggregate") 242 | # Test normal call 243 | print(stdout) 244 | assert "Reinitialized existing Git repository" in stdout 245 | assert "pre-commit installed" in stdout 246 | invoke("start") 247 | # Test "--debugpy and wait time call 248 | safe_stop_env(tmp_path) 249 | stdout = invoke("start", "--debugpy") 250 | assert socket_is_open("127.0.0.1", int(supported_odoo_version) * 1000 + 899) 251 | # Check if auto-reload is disabled 252 | docker = DockerClient() 253 | container_logs = docker.compose.logs("odoo") 254 | assert "dev=reload" not in container_logs 255 | finally: 256 | safe_stop_env( 257 | tmp_path, 258 | ) 259 | 260 | 261 | @pytest.mark.sequential 262 | def test_install_test( 263 | cloned_template: Path, 264 | supported_odoo_version: float, 265 | tmp_path: Path, 266 | ): 267 | """Test the install and test tasks. 268 | 269 | On this test flow, other downsream tasks are also tested: 270 | 271 | - img-build 272 | - git-aggregate 273 | - stop --purge 274 | """ 275 | try: 276 | with local.cwd(tmp_path): 277 | data = { 278 | "odoo_version": supported_odoo_version, 279 | "postgres_version": DBVER_PER_ODOO[supported_odoo_version]["latest"], 280 | } 281 | run_copy( 282 | src_path=str(cloned_template), 283 | data=data, 284 | vcs_ref="HEAD", 285 | defaults=True, 286 | overwrite=True, 287 | unsafe=True, 288 | ) 289 | # Imagine the user is in the src subfolder for these tasks 290 | # and the DB is clean 291 | with local.cwd(tmp_path / "odoo" / "custom" / "src"): 292 | invoke("img-build") 293 | invoke("git-aggregate") 294 | invoke("resetdb") 295 | # Install "mail" 296 | assert _install_status("mail") == "uninstalled" 297 | stdout = invoke("install", "-m", "mail") 298 | assert _install_status("mail") == "installed" 299 | if supported_odoo_version > 8: 300 | assert _install_status("utm") == "uninstalled" 301 | # Change to "utm" subfolder and install 302 | with local.cwd( 303 | tmp_path / "odoo" / "custom" / "src" / "odoo" / "addons" / "utm" 304 | ): 305 | # Install "utm" based on current folder 306 | stdout = invoke("install") 307 | assert _install_status("mail") == "installed" 308 | assert _install_status("utm") == "installed" 309 | # Test "note" or "project_todo" simple call in init mode (default) 310 | module_name = "note" if supported_odoo_version < 17 else "project_todo" 311 | assert _install_status(module_name) == "uninstalled" 312 | stdout = invoke("test", "-m", module_name, "--mode", "init", retcode=None) 313 | # Ensure module was installed and tests ran 314 | assert _install_status(module_name) == "installed" 315 | _tests_ran(stdout, supported_odoo_version, module_name) 316 | # Test module simple call in update mode 317 | stdout = invoke("test", "-m", module_name, "--mode", "update", retcode=None) 318 | _tests_ran(stdout, supported_odoo_version, module_name) 319 | # Change to subfolder and test 320 | with local.cwd( 321 | tmp_path / "odoo" / "custom" / "src" / "odoo" / "addons" / module_name 322 | ): 323 | # Test module based on current folder 324 | stdout = invoke("test", retcode=None) 325 | _tests_ran(stdout, supported_odoo_version, module_name) 326 | # Test --debugpy and wait time call with 327 | safe_stop_env(tmp_path, purge=False) 328 | invoke("test", "-m", module_name, "--debugpy", retcode=None) 329 | assert socket_is_open("127.0.0.1", int(supported_odoo_version) * 1000 + 899) 330 | stdout = _wait_for_test_to_start() 331 | assert "python -m debugpy" in stdout 332 | finally: 333 | safe_stop_env( 334 | tmp_path, 335 | ) 336 | 337 | 338 | @pytest.mark.sequential 339 | @pytest.mark.skip_for_prereleases 340 | def test_test_tasks( 341 | cloned_template: Path, 342 | supported_odoo_version: float, 343 | tmp_path: Path, 344 | ): 345 | """Test the tasks associated with the Odoo test flow. 346 | 347 | On this test flow, the following tasks are tested: 348 | 349 | - img-build 350 | - git-aggregate 351 | - stop --purge 352 | - resetdb --dependencies 353 | - test [options] 354 | 355 | This test will be skipped for prereleased versions of Doodba 356 | """ 357 | try: 358 | with local.cwd(tmp_path): 359 | data = { 360 | "odoo_version": supported_odoo_version, 361 | "postgres_version": DBVER_PER_ODOO[supported_odoo_version]["latest"], 362 | } 363 | run_copy( 364 | src_path=str(cloned_template), 365 | data=data, 366 | vcs_ref="HEAD", 367 | defaults=True, 368 | overwrite=True, 369 | unsafe=True, 370 | ) 371 | # Imagine the user is in the src subfolder for these tasks 372 | # and the DB is clean 373 | with local.cwd(tmp_path / "odoo" / "custom" / "src"): 374 | invoke("img-build") 375 | invoke("git-aggregate") 376 | module_name = "note" if supported_odoo_version < 17 else "project_todo" 377 | # Prepare environment with "note" or "project_todo" dependencies 378 | invoke("resetdb", "-m", module_name, "--dependencies") 379 | assert _install_status("mail") == "installed" 380 | # Test module simple call in init mode (default) 381 | if supported_odoo_version >= 17: 382 | # Uninstall module 383 | invoke("uninstall", "-m", module_name) 384 | assert _install_status(module_name) == "uninstalled" 385 | stdout = invoke("test", "-m", module_name, retcode=None) 386 | # Ensure module was installed and tests ran 387 | assert _install_status(module_name) == "installed" 388 | _tests_ran(stdout, supported_odoo_version, module_name) 389 | if supported_odoo_version >= 11: 390 | # Prepare environment for all private addons and "test" them 391 | with local.cwd(tmp_path / "odoo" / "custom" / "src" / "private"): 392 | generate_test_addon( 393 | "test_module", supported_odoo_version, dependencies='["mail"]' 394 | ) 395 | invoke("resetdb", "--private", "--dependencies") 396 | assert _install_status("mail") == "installed" 397 | # Test "test_module" simple call in init mode (default) 398 | assert _install_status("test_module") == "uninstalled" 399 | stdout = invoke("test", "--private", retcode=None) 400 | # Ensure "test_module" was installed and tests ran 401 | assert _install_status("test_module") == "installed" 402 | # Prepare environment for OCA addons and test them 403 | with local.cwd(tmp_path / "odoo" / "custom" / "src"): 404 | build_file_tree( 405 | { 406 | "addons.yaml": """\ 407 | account-invoicing: 408 | - account_invoice_refund_link 409 | """, 410 | } 411 | ) 412 | invoke("git-aggregate") 413 | if supported_odoo_version < 18.0: 414 | # TODO: Put 19.0 once 'account_invoice_refund_link' is migrated to Odoo 18.0 415 | # Skip the tests for 'account_invoice_refund_link' as it's not available yet 416 | invoke("resetdb", "--extra", "--private", "--dependencies") 417 | assert ( 418 | _install_status("mail") == "installed" 419 | ) # dependency of test_module 420 | assert ( 421 | _install_status("account") == "installed" 422 | ) # dependency of account_invoice_refund_link 423 | # Test "account_invoice_refund_link" 424 | assert _install_status("test_module") == "uninstalled" 425 | assert ( 426 | _install_status("account_invoice_refund_link") == "uninstalled" 427 | ) 428 | stdout = invoke("test", "--private", "--extra", retcode=None) 429 | # Ensure "test_module" and "account_invoice_refund_link" were installed 430 | assert _install_status("test_module") == "installed" 431 | assert _install_status("account_invoice_refund_link") == "installed" 432 | _tests_ran( 433 | stdout, supported_odoo_version, "account_invoice_refund_link" 434 | ) 435 | # Test --test-tags 436 | if supported_odoo_version >= 12 and supported_odoo_version < 18.0: 437 | # TODO: Put 19.0 once 'account_invoice_refund_link' is migrated to Odoo 18.0 438 | # Skip the tests for 'account_invoice_refund_link' as it's not available yet 439 | with local.cwd(tmp_path / "odoo" / "custom" / "src" / "private"): 440 | generate_test_addon( 441 | "test_module", 442 | supported_odoo_version, 443 | dependencies='["account_invoice_refund_link"]', 444 | ) 445 | # Run again but skip tests 446 | invoke("resetdb", "--extra", "--private", "--dependencies") 447 | stdout = invoke( 448 | "test", 449 | "--private", 450 | "--extra", 451 | "--skip", 452 | "account_invoice_refund_link", 453 | retcode=None, 454 | ) 455 | assert _install_status("test_module") == "installed" 456 | assert _install_status("account_invoice_refund_link") == "installed" 457 | # Tests for account_invoice_refund_link should not run 458 | with pytest.raises(AssertionError): 459 | _tests_ran( 460 | stdout, 461 | supported_odoo_version, 462 | "account_invoice_refund_link", 463 | ) 464 | finally: 465 | safe_stop_env( 466 | tmp_path, 467 | ) 468 | --------------------------------------------------------------------------------